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.ZoneTransferIncoming" as const;
13type TypeId = typeof TypeId;
14
15export interface ZoneTransferIncomingProps {
16 /**
17 * Secondary zone whose incoming 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 transfers the zone in from. Reference
30 * {@link ZoneTransferPeer} resources via their `peerId` attribute.
31 *
32 * Mutable — updated in place (PUT).
33 */
34 peers: string[];
35 /**
36 * How often (seconds) the secondary zone auto-refreshes regardless of
37 * DNS NOTIFY.
38 *
39 * Mutable — updated in place (PUT).
40 */
41 autoRefreshSeconds: number;
42}
43
44export interface ZoneTransferIncomingAttributes {
45 /** Zone whose incoming transfer configuration is managed. */
46 zoneId: string;
47 /** Identifier of the configuration (mirrors the zone id). */
48 id: string | undefined;
49 /** Zone name. */
50 name: string | undefined;
51 /** Peer ids the zone transfers in from. */
52 peers: string[];
53 /** Auto-refresh interval in seconds. */
54 autoRefreshSeconds: number | undefined;
55 /** SOA serial of the most recent transfer. */
56 soaSerial: number | undefined;
57 /** When the zone was last checked. */
58 checkedTime: string | undefined;
59 /** When the configuration was created. */
60 createdTime: string | undefined;
61 /** When the configuration was last modified. */
62 modifiedTime: string | undefined;
63}
64
65export type ZoneTransferIncoming = Resource<
66 TypeId,
67 ZoneTransferIncomingProps,
68 ZoneTransferIncomingAttributes,
69 never,
70 Providers
71>;
72
73/**
74 * The incoming zone-transfer configuration of a secondary zone
75 * (`/zones/{zone_id}/secondary_dns/incoming`) — links the zone to the
76 * {@link ZoneTransferPeer | peers} Cloudflare transfers it in from and
77 * sets the auto-refresh interval.
78 *
79 * Requires the Secondary DNS (zone transfer) entitlement, and the zone
80 * must be created with `type: "secondary"`. The configuration is a
81 * per-zone singleton: `zoneId` is the identity (replacement on change),
82 * everything else is mutable in place.
83 * ### Configuring incoming transfers
84 * **Example:** Transfer a secondary zone in from a primary
85 * ```typescript
86 * const peer = yield* Cloudflare.DNS.ZoneTransferPeer("Primary", {
87 * ip: "192.0.2.53",
88 * port: 53,
89 * });
90 * yield* Cloudflare.DNS.ZoneTransferIncoming("Incoming", {
91 * zoneId: zone.zoneId,
92 * name: "example.com.",
93 * peers: [peer.peerId],
94 * autoRefreshSeconds: 86400,
95 * });
96 * ```
97 *
98 * @see https://developers.cloudflare.com/dns/zone-setups/zone-transfers/setup/
99 *
100 * @resource
101 * @product DNS
102 * @category Domains & DNS
103 */
104export const ZoneTransferIncoming = Resource<ZoneTransferIncoming>(TypeId, {
105 aliases: ["Cloudflare.Dns.ZoneTransferIncoming"],
106});
107
108/**
109 * Returns true if the given value is a ZoneTransferIncoming resource.
110 */
111export const isZoneTransferIncoming = (
112 value: unknown,
113): value is ZoneTransferIncoming =>
114 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
115
116export const ZoneTransferIncomingProvider = () =>
117 Provider.succeed(ZoneTransferIncoming, {
118 stables: ["zoneId", "id", "createdTime"],
119
120 list: Effect.fn(function* () {
121 const { accountId } = yield* yield* CloudflareEnvironment;
122 // No account-wide API for this per-zone singleton — enumerate
123 // every zone and read its incoming transfer config. Only
124 // secondary zones with a configured transfer have one, so skip
125 // the rest (`IncomingZoneTransferNotFound`).
126 const allZones = yield* listAllZones(accountId);
127 const rows = yield* Effect.forEach(
128 allZones.map((zone) => zone.id),
129 (zoneId) =>
130 getIncoming(zoneId).pipe(
131 Effect.map((observed) =>
132 observed === undefined
133 ? undefined
134 : toAttributes(observed, zoneId),
135 ),
136 ),
137 { concurrency: 10 },
138 );
139 return rows.filter(
140 (row): row is ZoneTransferIncomingAttributes => row !== undefined,
141 );
142 }),
143
144 diff: Effect.fn(function* ({ olds = {}, news, output }) {
145 const o = olds as ZoneTransferIncomingProps;
146 const n = news as ZoneTransferIncomingProps;
147 // zoneId is the resource's identity (per-zone singleton).
148 // Input<string> — compare only once concrete.
149 const oldZoneId =
150 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
151 if (
152 oldZoneId !== undefined &&
153 typeof n.zoneId === "string" &&
154 oldZoneId !== n.zoneId
155 ) {
156 return { action: "replace" } as const;
157 }
158 return undefined;
159 }),
160
161 read: Effect.fn(function* ({ output, olds }) {
162 const zoneId =
163 output?.zoneId ??
164 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
165 if (!zoneId) return undefined;
166 const observed = yield* getIncoming(zoneId);
167 if (observed === undefined) return undefined;
168 const attrs = toAttributes(observed, zoneId);
169 // The configuration carries no ownership markers. With no prior
170 // state, gate takeover of an existing configuration behind
171 // adoption.
172 return output === undefined ? Unowned(attrs) : attrs;
173 }),
174
175 reconcile: Effect.fn(function* ({ news }) {
176 // Inputs are resolved to concrete values by Plan.
177 const zoneId = news.zoneId as string;
178 const peers = news.peers as string[];
179
180 // Observe — singleton keyed by the zone itself.
181 const observed = yield* getIncoming(zoneId);
182
183 if (!observed) {
184 // Ensure — first-time link of the zone to its peers.
185 const created = yield* dns.createZoneTransferIncoming({
186 zoneId,
187 name: news.name,
188 peers,
189 autoRefreshSeconds: news.autoRefreshSeconds,
190 });
191 return toAttributes(created, zoneId);
192 }
193
194 // Sync — PUT with the full desired body; skip the call when the
195 // observed configuration already matches.
196 const dirty =
197 undef(observed.name) !== news.name ||
198 undef(observed.autoRefreshSeconds) !== news.autoRefreshSeconds ||
199 !samePeers(observed.peers ?? [], peers);
200 if (!dirty) {
201 return toAttributes(observed, zoneId);
202 }
203 const updated = yield* dns.updateZoneTransferIncoming({
204 zoneId,
205 name: news.name,
206 peers,
207 autoRefreshSeconds: news.autoRefreshSeconds,
208 });
209 return toAttributes(updated, zoneId);
210 }),
211
212 delete: Effect.fn(function* ({ output }) {
213 yield* dns
214 .deleteZoneTransferIncoming({ zoneId: output.zoneId })
215 .pipe(
216 Effect.catchTag("IncomingZoneTransferNotFound", () => Effect.void),
217 );
218 }),
219 });
220
221type ObservedIncoming =
222 | dns.GetZoneTransferIncomingResponse
223 | dns.CreateZoneTransferIncomingResponse
224 | dns.UpdateZoneTransferIncomingResponse;
225
226const undef = <T>(v: T | null | undefined): T | undefined =>
227 v == null ? undefined : v;
228
229/** Read the incoming configuration, mapping "not linked" to undefined. */
230const getIncoming = (zoneId: string) =>
231 dns
232 .getZoneTransferIncoming({ zoneId })
233 .pipe(
234 Effect.catchTag("IncomingZoneTransferNotFound", () =>
235 Effect.succeed(undefined),
236 ),
237 );
238
239const samePeers = (observed: readonly string[], desired: readonly string[]) =>
240 observed.length === desired.length &&
241 [...observed].sort().join(",") === [...desired].sort().join(",");
242
243const toAttributes = (
244 incoming: ObservedIncoming,
245 zoneId: string,
246): ZoneTransferIncomingAttributes => ({
247 zoneId,
248 id: undef(incoming.id),
249 name: undef(incoming.name),
250 peers: [...(undef(incoming.peers) ?? [])],
251 autoRefreshSeconds: undef(incoming.autoRefreshSeconds),
252 soaSerial: undef(incoming.soaSerial),
253 checkedTime: undef(incoming.checkedTime),
254 createdTime: undef(incoming.createdTime),
255 modifiedTime: undef(incoming.modifiedTime),
256});