1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12const TypeId = "Cloudflare.DNS.ZoneTransferOutgoing" as const;
13type TypeId = typeof TypeId;
14
15export interface ZoneTransferOutgoingProps {
16 /**
17 * Primary zone whose outgoing transfer configuration is managed.
18 * Stable — the configuration is a per-zone singleton, so changing the
19 * zone triggers a replacement.
20 */
21 zoneId: string;
22 /**
23 * Zone name (e.g. `example.com.`).
24 *
25 * Mutable — updated in place (PUT).
26 */
27 name: string;
28 /**
29 * Peers (by id) Cloudflare NOTIFYs and serves AXFR/IXFR to.
30 * Reference {@link ZoneTransferPeer} resources via their `peerId`
31 * attribute.
32 *
33 * Mutable — updated in place (PUT).
34 */
35 peers: string[];
36 /**
37 * Whether outgoing transfers are enabled for the zone. Mapped to the
38 * dedicated enable/disable endpoints, separate from the
39 * configuration's CRUD.
40 *
41 * Mutable — toggled in place.
42 * @default true
43 */
44 enabled?: boolean;
45}
46
47export interface ZoneTransferOutgoingAttributes {
48 /** Zone whose outgoing transfer configuration is managed. */
49 zoneId: string;
50 /** Identifier of the configuration (mirrors the zone id). */
51 id: string | undefined;
52 /** Zone name. */
53 name: string | undefined;
54 /** Peer ids the zone is served to. */
55 peers: string[];
56 /** Whether outgoing transfers are enabled. */
57 enabled: boolean;
58 /** SOA serial of the most recent transfer. */
59 soaSerial: number | undefined;
60 /** When the zone was last transferred out. */
61 lastTransferredTime: string | undefined;
62 /** When the zone was last checked. */
63 checkedTime: string | undefined;
64 /** When the configuration was created. */
65 createdTime: string | undefined;
66}
67
68export type ZoneTransferOutgoing = Resource<
69 TypeId,
70 ZoneTransferOutgoingProps,
71 ZoneTransferOutgoingAttributes,
72 never,
73 Providers
74>;
75
76/**
77 * The outgoing zone-transfer configuration of a primary zone
78 * (`/zones/{zone_id}/secondary_dns/outgoing`) — links the zone to the
79 * {@link ZoneTransferPeer | peers} Cloudflare NOTIFYs and serves
80 * AXFR/IXFR to, and toggles transfers on or off via the dedicated
81 * enable/disable endpoints.
82 *
83 * Requires the Secondary DNS (zone transfer) entitlement on the zone.
84 * The configuration is a per-zone singleton: `zoneId` is the identity
85 * (replacement on change), everything else is mutable in place.
86 * ### Configuring outgoing transfers
87 * **Example:** Serve a primary zone to an external secondary
88 * ```typescript
89 * const peer = yield* Cloudflare.DNS.ZoneTransferPeer("Secondary", {
90 * ip: "192.0.2.53",
91 * port: 53,
92 * });
93 * yield* Cloudflare.DNS.ZoneTransferOutgoing("Outgoing", {
94 * zoneId: zone.zoneId,
95 * name: "example.com.",
96 * peers: [peer.peerId],
97 * });
98 * ```
99 *
100 * **Example:** Configure transfers but keep them disabled
101 * ```typescript
102 * yield* Cloudflare.DNS.ZoneTransferOutgoing("Outgoing", {
103 * zoneId: zone.zoneId,
104 * name: "example.com.",
105 * peers: [peer.peerId],
106 * enabled: false,
107 * });
108 * ```
109 *
110 * @see https://developers.cloudflare.com/dns/zone-setups/zone-transfers/setup/
111 *
112 * @resource
113 * @product DNS
114 * @category Domains & DNS
115 */
116export const ZoneTransferOutgoing = Resource<ZoneTransferOutgoing>(TypeId, {
117 aliases: ["Cloudflare.Dns.ZoneTransferOutgoing"],
118});
119
120/**
121 * Returns true if the given value is a ZoneTransferOutgoing resource.
122 */
123export const isZoneTransferOutgoing = (
124 value: unknown,
125): value is ZoneTransferOutgoing =>
126 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
127
128export const ZoneTransferOutgoingProvider = () =>
129 Provider.succeed(ZoneTransferOutgoing, {
130 stables: ["zoneId", "id", "createdTime"],
131
132 list: Effect.fn(function* () {
133 const { accountId } = yield* yield* CloudflareEnvironment;
134 // The outgoing transfer config is a per-zone singleton with no
135 // account-wide enumeration API — walk every zone and read its
136 // config. Most zones have none (or lack the Secondary DNS
137 // entitlement); skip those via the typed not-found/not-allowed
138 // tags rather than failing the whole enumeration.
139 const allZones = yield* listAllZones(accountId);
140 const rows = yield* Effect.forEach(
141 allZones.map((zone) => zone.id),
142 (zoneId) =>
143 getOutgoing(zoneId).pipe(
144 Effect.catchTag("OutgoingZoneTransfersNotAllowed", () =>
145 Effect.succeed(undefined),
146 ),
147 Effect.flatMap((observed) =>
148 observed === undefined
149 ? Effect.succeed(undefined)
150 : getEnabled(zoneId).pipe(
151 Effect.map((enabled) =>
152 toAttributes(observed, zoneId, enabled),
153 ),
154 ),
155 ),
156 ),
157 { concurrency: 10 },
158 );
159 return rows.filter(
160 (row): row is ZoneTransferOutgoingAttributes => row !== undefined,
161 );
162 }),
163
164 diff: Effect.fn(function* ({ olds = {}, news, output }) {
165 const o = olds as ZoneTransferOutgoingProps;
166 const n = news as ZoneTransferOutgoingProps;
167 // zoneId is the resource's identity (per-zone singleton).
168 // Input<string> — compare only once concrete.
169 const oldZoneId =
170 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
171 if (
172 oldZoneId !== undefined &&
173 typeof n.zoneId === "string" &&
174 oldZoneId !== n.zoneId
175 ) {
176 return { action: "replace" } as const;
177 }
178 return undefined;
179 }),
180
181 read: Effect.fn(function* ({ output, olds }) {
182 const zoneId =
183 output?.zoneId ??
184 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
185 if (!zoneId) return undefined;
186 const observed = yield* getOutgoing(zoneId);
187 if (observed === undefined) return undefined;
188 const enabled = yield* getEnabled(zoneId);
189 const attrs = toAttributes(observed, zoneId, enabled);
190 // The configuration carries no ownership markers. With no prior
191 // state, gate takeover of an existing configuration behind
192 // adoption.
193 return output === undefined ? Unowned(attrs) : attrs;
194 }),
195
196 reconcile: Effect.fn(function* ({ news }) {
197 // Inputs are resolved to concrete values by Plan.
198 const zoneId = news.zoneId as string;
199 const peers = news.peers as string[];
200 const desiredEnabled = news.enabled ?? true;
201
202 // Observe — singleton keyed by the zone itself.
203 let observed = yield* getOutgoing(zoneId);
204
205 if (!observed) {
206 // Ensure — first-time link of the zone to its peers.
207 observed = yield* dns.createZoneTransferOutgoing({
208 zoneId,
209 name: news.name,
210 peers,
211 });
212 } else {
213 // Sync — PUT with the full desired body; skip the call when the
214 // observed configuration already matches.
215 const dirty =
216 undef(observed.name) !== news.name ||
217 !samePeers(observed.peers ?? [], peers);
218 if (dirty) {
219 observed = yield* dns.updateZoneTransferOutgoing({
220 zoneId,
221 name: news.name,
222 peers,
223 });
224 }
225 }
226
227 // Sync enabled state — the enable/disable toggle lives on its own
228 // endpoints; diff the observed status and only call on a delta.
229 const observedEnabled = yield* getEnabled(zoneId);
230 if (observedEnabled !== desiredEnabled) {
231 yield* desiredEnabled
232 ? dns.enableZoneTransferOutgoing({ zoneId })
233 : dns.disableZoneTransferOutgoing({ zoneId });
234 }
235
236 return toAttributes(observed, zoneId, desiredEnabled);
237 }),
238
239 delete: Effect.fn(function* ({ output }) {
240 yield* dns.deleteZoneTransferOutgoing({ zoneId: output.zoneId }).pipe(
241 Effect.catchTag("OutgoingZoneTransferNotFound", () => Effect.void),
242 // Cloudflare answers DELETE on a zone without the outgoing
243 // entitlement (or with no configuration) with a 401 — if we
244 // could never have created it, there is nothing to delete.
245 Effect.catchTag("OutgoingZoneTransfersNotAllowed", () => Effect.void),
246 );
247 }),
248 });
249
250type ObservedOutgoing =
251 | dns.GetZoneTransferOutgoingResponse
252 | dns.CreateZoneTransferOutgoingResponse
253 | dns.UpdateZoneTransferOutgoingResponse;
254
255const undef = <T>(v: T | null | undefined): T | undefined =>
256 v == null ? undefined : v;
257
258/** Read the outgoing configuration, mapping "not linked" to undefined. */
259const getOutgoing = (zoneId: string) =>
260 dns
261 .getZoneTransferOutgoing({ zoneId })
262 .pipe(
263 Effect.catchTag("OutgoingZoneTransferNotFound", () =>
264 Effect.succeed(undefined),
265 ),
266 );
267
268/** Observe the enable/disable toggle (reported as e.g. "Enabled"). */
269const getEnabled = (zoneId: string) =>
270 dns
271 .getZoneTransferOutgoingStatus({ zoneId })
272 .pipe(Effect.map((status) => status.toLowerCase().startsWith("enabled")));
273
274const samePeers = (observed: readonly string[], desired: readonly string[]) =>
275 observed.length === desired.length &&
276 [...observed].sort().join(",") === [...desired].sort().join(",");
277
278const toAttributes = (
279 outgoing: ObservedOutgoing,
280 zoneId: string,
281 enabled: boolean,
282): ZoneTransferOutgoingAttributes => ({
283 zoneId,
284 id: undef(outgoing.id),
285 name: undef(outgoing.name),
286 peers: [...(undef(outgoing.peers) ?? [])],
287 enabled,
288 soaSerial: undef(outgoing.soaSerial),
289 lastTransferredTime: undef(outgoing.lastTransferredTime),
290 checkedTime: undef(outgoing.checkedTime),
291 createdTime: undef(outgoing.createdTime),
292});