1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.DNS.ZoneTransferPeer" as const;
14type TypeId = typeof TypeId;
15
16export interface ZoneTransferPeerProps {
17 /**
18 * Human-readable name of the peer. If omitted, a unique name is
19 * generated from the app, stage, and logical ID.
20 *
21 * Mutable — updated in place (PUT).
22 * @default ${app}-${stage}-${id}
23 */
24 name?: string;
25 /**
26 * IPv4/IPv6 address of the primary or secondary nameserver, depending
27 * on what zone this peer is linked to. For primary (outgoing) zones
28 * this is the secondary nameserver Cloudflare will NOTIFY; for
29 * secondary (incoming) zones it is the primary Cloudflare transfers
30 * from.
31 *
32 * Mutable — updated in place (PUT).
33 */
34 ip?: string;
35 /**
36 * DNS port of the primary or secondary nameserver.
37 *
38 * Mutable — updated in place (PUT).
39 * @default 53
40 */
41 port?: number;
42 /**
43 * TSIG used to authenticate zone transfers with this peer. Reference
44 * a {@link ZoneTransferTsig} via its `tsigId` attribute.
45 *
46 * Mutable — updated in place (PUT).
47 */
48 tsigId?: string;
49 /**
50 * Use the IXFR (incremental) transfer protocol instead of AXFR. Only
51 * applicable to secondary zones.
52 *
53 * Mutable — updated in place (PUT).
54 * @default false
55 */
56 ixfrEnable?: boolean;
57}
58
59export interface ZoneTransferPeerAttributes {
60 /** Identifier of the peer. */
61 peerId: string;
62 /** The Cloudflare account the peer belongs to. */
63 accountId: string;
64 /** Human-readable name of the peer. */
65 name: string;
66 /** Nameserver IP, if configured. */
67 ip: string | undefined;
68 /** Nameserver DNS port, if configured. */
69 port: number | undefined;
70 /** TSIG used to authenticate transfers, if configured. */
71 tsigId: string | undefined;
72 /** Whether IXFR transfers are enabled. */
73 ixfrEnable: boolean | undefined;
74}
75
76export type ZoneTransferPeer = Resource<
77 TypeId,
78 ZoneTransferPeerProps,
79 ZoneTransferPeerAttributes,
80 never,
81 Providers
82>;
83
84/**
85 * A Secondary DNS zone-transfer peer
86 * (`/accounts/{account_id}/secondary_dns/peers`) — an external
87 * nameserver Cloudflare exchanges zone transfers with. Link peers to a
88 * zone via {@link ZoneTransferIncoming} (secondary zones) or
89 * {@link ZoneTransferOutgoing} (primary zones).
90 *
91 * Requires the Secondary DNS (zone transfer) entitlement on the
92 * account. Cloudflare's create API only accepts a name; the provider
93 * follows up with an update when `ip`, `port`, `tsigId`, or
94 * `ixfrEnable` are declared, all of which remain mutable in place.
95 * ### Creating a Peer
96 * **Example:** Primary nameserver to transfer from
97 * ```typescript
98 * const peer = yield* Cloudflare.DNS.ZoneTransferPeer("Primary", {
99 * ip: "192.0.2.53",
100 * port: 53,
101 * });
102 * ```
103 *
104 * **Example:** Peer with TSIG authentication
105 * ```typescript
106 * const tsig = yield* Cloudflare.DNS.ZoneTransferTsig("TransferKey", {
107 * algo: "hmac-sha512.",
108 * secret: Redacted.make(process.env.TSIG_SECRET!),
109 * });
110 * const peer = yield* Cloudflare.DNS.ZoneTransferPeer("Primary", {
111 * ip: "192.0.2.53",
112 * tsigId: tsig.tsigId,
113 * ixfrEnable: true,
114 * });
115 * ```
116 *
117 * @see https://developers.cloudflare.com/dns/zone-setups/zone-transfers/
118 *
119 * @resource
120 * @product DNS
121 * @category Domains & DNS
122 */
123export const ZoneTransferPeer = Resource<ZoneTransferPeer>(TypeId, {
124 aliases: ["Cloudflare.Dns.ZoneTransferPeer"],
125});
126
127/**
128 * Returns true if the given value is a ZoneTransferPeer resource.
129 */
130export const isZoneTransferPeer = (value: unknown): value is ZoneTransferPeer =>
131 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
132
133export const ZoneTransferPeerProvider = () =>
134 Provider.succeed(ZoneTransferPeer, {
135 stables: ["peerId", "accountId"],
136
137 list: Effect.fn(function* () {
138 const { accountId } = yield* yield* CloudflareEnvironment;
139 // Account-scoped collection (pattern b) — secondary_dns peers
140 // live under the account, not a zone. Exhaustively paginate and
141 // hydrate each row into the exact `read` Attributes shape.
142 return yield* dns.listZoneTransferPeers.pages({ accountId }).pipe(
143 Stream.runCollect,
144 Effect.map((chunk) =>
145 Array.from(chunk).flatMap((page) =>
146 (page.result ?? []).map((peer) => toAttributes(peer, accountId)),
147 ),
148 ),
149 );
150 }),
151
152 diff: Effect.fn(function* ({ output }) {
153 const { accountId } = yield* yield* CloudflareEnvironment;
154 if (output !== undefined && output.accountId !== accountId) {
155 return { action: "replace" } as const;
156 }
157 return undefined;
158 }),
159
160 read: Effect.fn(function* ({ id, output, olds }) {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162 const acct = output?.accountId ?? accountId;
163
164 if (output?.peerId) {
165 const observed = yield* getPeer(acct, output.peerId);
166 return observed ? toAttributes(observed, acct) : undefined;
167 }
168 // Cold read — recover from lost state by matching the
169 // deterministic physical name. Peers carry no ownership markers,
170 // so gate takeover behind adoption.
171 const name = yield* createPeerName(id, olds?.name);
172 const match = yield* findByName(acct, name);
173 return match ? Unowned(toAttributes(match, acct)) : undefined;
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output }) {
177 const { accountId } = yield* yield* CloudflareEnvironment;
178 const name = yield* createPeerName(id, news.name);
179 // Inputs are resolved to concrete values by Plan.
180 const desired = {
181 name,
182 ip: news.ip as string | undefined,
183 port: news.port,
184 tsigId: news.tsigId as string | undefined,
185 ixfrEnable: news.ixfrEnable,
186 };
187
188 // Observe — the id cached on `output` is a hint, not a guarantee.
189 let observed = output?.peerId
190 ? yield* getPeer(output.accountId ?? accountId, output.peerId)
191 : undefined;
192 const acct = observed ? (output?.accountId ?? accountId) : accountId;
193
194 if (!observed) {
195 // Ensure — Cloudflare's create only accepts `name`; the
196 // connection settings are applied by the sync step below.
197 observed = yield* dns.createZoneTransferPeer({ accountId, name });
198 }
199
200 // Sync — PUT with the full desired body; skip the call when no
201 // declared field differs from the observed cloud state.
202 const dirty =
203 observed.name !== desired.name ||
204 (desired.ip !== undefined && undef(observed.ip) !== desired.ip) ||
205 (desired.port !== undefined && undef(observed.port) !== desired.port) ||
206 (desired.tsigId !== undefined &&
207 undef(observed.tsigId) !== desired.tsigId) ||
208 (desired.ixfrEnable !== undefined &&
209 undef(observed.ixfrEnable) !== desired.ixfrEnable);
210
211 if (!dirty) {
212 return toAttributes(observed, acct);
213 }
214 const updated = yield* dns.updateZoneTransferPeer({
215 accountId: acct,
216 peerId: observed.id,
217 name: desired.name,
218 ip: desired.ip ?? undef(observed.ip),
219 port: desired.port ?? undef(observed.port),
220 tsigId: desired.tsigId ?? undef(observed.tsigId),
221 ixfrEnable: desired.ixfrEnable ?? undef(observed.ixfrEnable),
222 });
223 return toAttributes(updated, acct);
224 }),
225
226 delete: Effect.fn(function* ({ output }) {
227 yield* dns
228 .deleteZoneTransferPeer({
229 accountId: output.accountId,
230 peerId: output.peerId,
231 })
232 .pipe(Effect.catchTag("PeerNotFound", () => Effect.void));
233 }),
234 });
235
236type ObservedPeer =
237 | dns.GetZoneTransferPeerResponse
238 | dns.CreateZoneTransferPeerResponse
239 | dns.UpdateZoneTransferPeerResponse;
240
241const undef = <T>(v: T | null | undefined): T | undefined =>
242 v == null ? undefined : v;
243
244/** Read a peer by id, mapping "gone" (404) to `undefined`. */
245const getPeer = (accountId: string, peerId: string) =>
246 dns
247 .getZoneTransferPeer({ accountId, peerId })
248 .pipe(Effect.catchTag("PeerNotFound", () => Effect.succeed(undefined)));
249
250/**
251 * Find a peer by exact name. Names are not unique on Cloudflare's side;
252 * pick the lexicographically-first id for determinism.
253 */
254const findByName = (accountId: string, name: string) =>
255 dns.listZoneTransferPeers.items({ accountId }).pipe(
256 Stream.filter((p) => p.name === name),
257 Stream.runCollect,
258 Effect.map((chunk) =>
259 Array.from(chunk)
260 .sort((a, b) => a.id.localeCompare(b.id))
261 .at(0),
262 ),
263 );
264
265const createPeerName = (id: string, name: string | undefined) =>
266 Effect.gen(function* () {
267 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
268 });
269
270const toAttributes = (
271 peer: ObservedPeer,
272 accountId: string,
273): ZoneTransferPeerAttributes => ({
274 peerId: peer.id,
275 accountId,
276 name: peer.name,
277 // Cloudflare returns "" / 0 for unset ip/port on freshly-created
278 // peers — normalize those to undefined.
279 ip: undef(peer.ip) || undefined,
280 port: undef(peer.port) || undefined,
281 tsigId: undef(peer.tsigId) || undefined,
282 ixfrEnable: undef(peer.ixfrEnable),
283});