1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9import { listAllZones } from "../Zone/lookup.ts";
10
11const TypeId = "Cloudflare.DNS.ZoneSettings" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * Nameserver assignment for the zone. `custom.*` types require
16 * Business/Enterprise account-level custom nameservers (ACNS).
17 */
18export interface ZoneDnsNameservers {
19 /**
20 * Nameserver kind: Cloudflare's standard pair, or custom nameservers
21 * defined at the account, tenant, or zone level.
22 */
23 type:
24 | "cloudflare.standard"
25 | "custom.account"
26 | "custom.tenant"
27 | "custom.zone"
28 | (string & {});
29 /**
30 * Which configured nameserver set to use (for `custom.account` /
31 * `custom.tenant`).
32 */
33 nsSet?: number;
34}
35
36/**
37 * Components of the zone's SOA record. Every field is optional —
38 * omitted fields keep their current value.
39 */
40export interface ZoneDnsSoa {
41 /** Time in seconds after which secondaries stop answering (`expire`). */
42 expire?: number;
43 /** Negative-caching TTL in seconds (`minimum`). */
44 minTtl?: number;
45 /** Primary nameserver (`MNAME`). */
46 mname?: string;
47 /** Secondary refresh interval in seconds. */
48 refresh?: number;
49 /** Secondary retry interval in seconds. */
50 retry?: number;
51 /** Zone administrator mailbox (`RNAME`). */
52 rname?: string;
53 /** TTL of the SOA record itself. */
54 ttl?: number;
55}
56
57export interface ZoneDnsSettingsProps {
58 /**
59 * Zone whose DNS settings are managed. Stable — the settings object
60 * is a per-zone singleton, so changing the zone triggers a
61 * replacement (the old zone's managed settings are restored to their
62 * pre-management values).
63 */
64 zoneId: string;
65 /**
66 * Flatten all CNAME records in the zone (a CNAME at the zone apex is
67 * always flattened regardless).
68 *
69 * Mutable — patched in place.
70 *
71 * @default false
72 */
73 flattenAllCnames?: boolean;
74 /**
75 * Enable Foundation DNS Advanced Nameservers (paid add-on — patching
76 * `true` fails without the entitlement).
77 *
78 * Mutable — patched in place.
79 *
80 * @default false
81 */
82 foundationDns?: boolean;
83 /**
84 * Settings for internal zones (Enterprise Internal DNS only).
85 *
86 * Mutable — patched in place.
87 */
88 internalDns?: {
89 /** Zone to resolve from when this internal zone has no match. */
90 referenceZoneId?: string;
91 };
92 /**
93 * Enable multi-provider DNS — activates the zone even when
94 * non-Cloudflare NS records exist and respects apex NS records during
95 * outbound zone transfers.
96 *
97 * Mutable — patched in place.
98 *
99 * @default false
100 */
101 multiProvider?: boolean;
102 /**
103 * Nameservers through which the zone should be available.
104 *
105 * Mutable — patched in place.
106 *
107 * @default { type: "cloudflare.standard" }
108 */
109 nameservers?: ZoneDnsNameservers;
110 /**
111 * TTL (seconds) of the zone's NS records.
112 *
113 * Mutable — patched in place.
114 *
115 * @default 86400
116 */
117 nsTtl?: number;
118 /**
119 * Allow a secondary zone to use proxied override records and CNAME
120 * flattening at the apex (secondary zones only).
121 *
122 * Mutable — patched in place.
123 *
124 * @default false
125 */
126 secondaryOverrides?: boolean;
127 /**
128 * Components of the zone's SOA record. Only the fields you provide
129 * are compared and patched; the rest keep their live values.
130 *
131 * Mutable — patched in place.
132 */
133 soa?: ZoneDnsSoa;
134 /**
135 * Whether the zone is a regular, CDN-only, or DNS-only zone.
136 *
137 * Mutable — patched in place.
138 *
139 * @default "standard"
140 */
141 zoneMode?: "standard" | "cdn_only" | "dns_only";
142}
143
144/**
145 * Fully-resolved snapshot of a zone's DNS settings as Cloudflare
146 * reports them (`GET /zones/{zone_id}/dns_settings`).
147 */
148export interface ZoneDnsSettingsSnapshot {
149 /** Whether all CNAMEs are flattened. */
150 flattenAllCnames: boolean;
151 /** Whether Foundation DNS is enabled. */
152 foundationDns: boolean;
153 /** Internal DNS reference zone, if configured. */
154 internalDns: { referenceZoneId: string | undefined };
155 /** Whether multi-provider DNS is enabled. */
156 multiProvider: boolean;
157 /** Resolved nameserver assignment. */
158 nameservers: { type: string; nsSet: number | undefined };
159 /** TTL of the zone's NS records. */
160 nsTtl: number;
161 /** Whether secondary overrides are enabled. */
162 secondaryOverrides: boolean;
163 /** Resolved SOA components. */
164 soa: {
165 expire: number | undefined;
166 minTtl: number | undefined;
167 mname: string | undefined;
168 refresh: number | undefined;
169 retry: number | undefined;
170 rname: string | undefined;
171 ttl: number | undefined;
172 };
173 /** Zone mode. */
174 zoneMode: string;
175}
176
177export interface ZoneDnsSettingsAttributes extends ZoneDnsSettingsSnapshot {
178 /** Zone whose DNS settings are managed. */
179 zoneId: string;
180 /**
181 * Snapshot of every DNS setting taken before Alchemy first patched
182 * the zone. The managed fields are restored from it on destroy, so
183 * deleting the resource puts the zone back the way it was found.
184 */
185 initialSettings: ZoneDnsSettingsSnapshot;
186 /**
187 * Which top-level settings this resource has managed (union across
188 * all reconciles). Only these are restored on destroy — settings the
189 * user never touched are left alone.
190 */
191 managedKeys: ReadonlyArray<string>;
192}
193
194export type ZoneDnsSettings = Resource<
195 TypeId,
196 ZoneDnsSettingsProps,
197 ZoneDnsSettingsAttributes,
198 never,
199 Providers
200>;
201
202/**
203 * The DNS settings of a Cloudflare zone
204 * (`/zones/{zone_id}/dns_settings`) — nameserver assignment, NS TTL,
205 * SOA components, CNAME flattening, multi-provider mode, and zone mode.
206 *
207 * The settings object is a per-zone singleton — it always exists with
208 * Cloudflare defaults, so this resource never creates or deletes
209 * anything physical. Reconcile patches only the fields you declare
210 * (and only when the observed value differs); destroy restores the
211 * managed fields to the values they had before Alchemy first touched
212 * the zone (captured as `initialSettings`).
213 *
214 * Some fields are plan-gated: `foundationDns` is a paid add-on,
215 * `nameservers.type: "custom.*"` requires account custom nameservers,
216 * `internalDns` and `secondaryOverrides` are Enterprise features.
217 * ### Basic settings
218 * **Example:** Lower the NS record TTL
219 * ```typescript
220 * yield* Cloudflare.DNS.ZoneDnsSettings("DnsSettings", {
221 * zoneId: zone.zoneId,
222 * nsTtl: 3600,
223 * });
224 * ```
225 *
226 * **Example:** Flatten every CNAME in the zone
227 * ```typescript
228 * yield* Cloudflare.DNS.ZoneDnsSettings("DnsSettings", {
229 * zoneId: zone.zoneId,
230 * flattenAllCnames: true,
231 * });
232 * ```
233 *
234 * ### SOA tuning
235 * **Example:** Shorten the negative-caching TTL
236 * ```typescript
237 * yield* Cloudflare.DNS.ZoneDnsSettings("DnsSettings", {
238 * zoneId: zone.zoneId,
239 * soa: { minTtl: 300 },
240 * });
241 * ```
242 *
243 * ### Multi-provider DNS
244 * **Example:** Serve the zone alongside another DNS provider
245 * ```typescript
246 * yield* Cloudflare.DNS.ZoneDnsSettings("DnsSettings", {
247 * zoneId: zone.zoneId,
248 * multiProvider: true,
249 * });
250 * ```
251 *
252 * @resource
253 * @product DNS
254 * @category Domains & DNS
255 */
256export const ZoneDnsSettings = Resource<ZoneDnsSettings>(TypeId, {
257 aliases: ["Cloudflare.Dns.ZoneSettings"],
258});
259
260/**
261 * Returns true if the given value is a ZoneDnsSettings resource.
262 */
263export const isZoneDnsSettings = (value: unknown): value is ZoneDnsSettings =>
264 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
265
266export const ZoneDnsSettingsProvider = () =>
267 Provider.succeed(ZoneDnsSettings, {
268 stables: ["zoneId", "initialSettings", "managedKeys"],
269
270 list: Effect.fn(function* () {
271 const { accountId } = yield* yield* CloudflareEnvironment;
272 // The settings object is a per-zone singleton with no account-wide
273 // list API — enumerate every zone and read its settings (every live
274 // zone always has one). Unmanaged here, so the observed snapshot is
275 // both the current state and the captured baseline.
276 const allZones = yield* listAllZones(accountId);
277 const rows = yield* Effect.forEach(
278 allZones.map((zone) => zone.id),
279 (zoneId) =>
280 dns.getSettingZone({ zoneId }).pipe(
281 Effect.map((observed) => {
282 const snapshot = toSnapshot(observed);
283 const attributes: ZoneDnsSettingsAttributes = {
284 zoneId,
285 ...snapshot,
286 initialSettings: snapshot,
287 managedKeys: [],
288 };
289 return attributes;
290 }),
291 // Plan-gated or partial zones reject the route; skip them.
292 // (Transient 403/429 "Authentication error" blips under
293 // concurrency are retried globally by the Cloudflare retry policy,
294 // so they never reach here as a real failure.)
295 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
296 ),
297 { concurrency: 10 },
298 );
299 return rows.filter(
300 (row): row is ZoneDnsSettingsAttributes => row !== undefined,
301 );
302 }),
303
304 diff: Effect.fn(function* ({ olds = {}, news, output }) {
305 const o = olds as ZoneDnsSettingsProps;
306 const n = news as ZoneDnsSettingsProps;
307 // zoneId is the resource's identity (the settings object is a
308 // zone singleton). Input<string> — compare only once concrete.
309 const oldZoneId =
310 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
311 if (
312 oldZoneId !== undefined &&
313 typeof n.zoneId === "string" &&
314 oldZoneId !== n.zoneId
315 ) {
316 return { action: "replace" } as const;
317 }
318 return undefined;
319 }),
320
321 read: Effect.fn(function* ({ output, olds }) {
322 const zoneId =
323 output?.zoneId ??
324 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
325 if (!zoneId) return undefined;
326 const observed = yield* dns.getSettingZone({ zoneId }).pipe(
327 // Zone deleted out-of-band — its settings are gone with it.
328 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
329 );
330 if (observed === undefined) return undefined;
331 const snapshot = toSnapshot(observed);
332 // Settings are a singleton that always exists with Cloudflare
333 // defaults — there is nothing to "own", so a cold read adopts
334 // freely (never `Unowned`). The observed snapshot at adoption
335 // time becomes the baseline restored on destroy.
336 return {
337 zoneId,
338 ...snapshot,
339 initialSettings: output?.initialSettings ?? snapshot,
340 managedKeys: output?.managedKeys ?? [],
341 } satisfies ZoneDnsSettingsAttributes;
342 }),
343
344 reconcile: Effect.fn(function* ({ news, output }) {
345 // Inputs have been resolved to concrete strings by Plan.
346 const zoneId = news.zoneId as string;
347
348 // 1. Observe — the settings object always exists for a live zone.
349 const observed = yield* dns.getSettingZone({ zoneId });
350 let snapshot = toSnapshot(observed);
351
352 // 2. Capture — the pre-management snapshot, restored on destroy.
353 const initialSettings = output?.initialSettings ?? snapshot;
354 // Track every key we have ever managed so destroy restores fields
355 // even after the user drops them from props.
356 const managedKeys = [
357 ...new Set([...(output?.managedKeys ?? []), ...declaredKeys(news)]),
358 ].sort();
359
360 // 3. Sync — patch only the declared fields whose observed value
361 // differs from the desired one; skip the API on no delta.
362 const delta = computeDelta(news, snapshot);
363 if (delta !== undefined) {
364 const patched = yield* dns.patchSettingZone({ zoneId, ...delta });
365 snapshot = toSnapshot(patched);
366 }
367
368 return {
369 zoneId,
370 ...snapshot,
371 initialSettings,
372 managedKeys,
373 } satisfies ZoneDnsSettingsAttributes;
374 }),
375
376 delete: Effect.fn(function* ({ output }) {
377 const { zoneId, initialSettings, managedKeys } = output;
378 // Observe — if the zone itself is gone, so are its settings.
379 const observed = yield* dns
380 .getSettingZone({ zoneId })
381 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
382 if (observed === undefined) return;
383 const snapshot = toSnapshot(observed);
384 // Restore only the fields this resource managed, and only the
385 // ones that still differ (idempotent re-delete after a crash).
386 const restore = computeRestore(managedKeys, initialSettings, snapshot);
387 if (restore === undefined) return;
388 yield* dns
389 .patchSettingZone({ zoneId, ...restore })
390 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
391 }),
392 });
393
394// ---------------------------------------------------------------------------
395// Snapshot + diff helpers
396// ---------------------------------------------------------------------------
397
398type SettingsResponse =
399 | dns.GetSettingZoneResponse
400 | dns.PatchSettingZoneResponse;
401
402type PatchBody = Omit<dns.PatchSettingZoneRequest, "zoneId">;
403
404const undef = <T>(v: T | null | undefined): T | undefined =>
405 v == null ? undefined : v;
406
407const toSnapshot = (r: SettingsResponse): ZoneDnsSettingsSnapshot => ({
408 flattenAllCnames: r.flattenAllCnames,
409 foundationDns: r.foundationDns,
410 internalDns: { referenceZoneId: undef(r.internalDns.referenceZoneId) },
411 multiProvider: r.multiProvider,
412 nameservers: {
413 type: r.nameservers.type,
414 nsSet: undef(r.nameservers.nsSet),
415 },
416 nsTtl: r.nsTtl,
417 secondaryOverrides: r.secondaryOverrides,
418 soa: {
419 expire: undef(r.soa.expire),
420 minTtl: undef(r.soa.minTtl),
421 mname: undef(r.soa.mname),
422 refresh: undef(r.soa.refresh),
423 retry: undef(r.soa.retry),
424 rname: undef(r.soa.rname),
425 ttl: undef(r.soa.ttl),
426 },
427 zoneMode: r.zoneMode,
428});
429
430/** Top-level prop keys (besides `zoneId`) the user declared. */
431const declaredKeys = (news: ZoneDnsSettingsProps): string[] =>
432 (
433 [
434 "flattenAllCnames",
435 "foundationDns",
436 "internalDns",
437 "multiProvider",
438 "nameservers",
439 "nsTtl",
440 "secondaryOverrides",
441 "soa",
442 "zoneMode",
443 ] as const
444 ).filter((k) => news[k] !== undefined);
445
446/**
447 * Merge desired SOA subfields over the observed SOA so the PATCH always
448 * carries a complete, consistent record (Cloudflare validates SOA as a
449 * whole). Drops fields that are unknown on both sides.
450 */
451const mergedSoa = (
452 desired: ZoneDnsSoa,
453 observed: ZoneDnsSettingsSnapshot["soa"],
454): NonNullable<PatchBody["soa"]> => ({
455 expire: desired.expire ?? observed.expire,
456 minTtl: desired.minTtl ?? observed.minTtl,
457 mname: desired.mname ?? observed.mname,
458 refresh: desired.refresh ?? observed.refresh,
459 retry: desired.retry ?? observed.retry,
460 rname: desired.rname ?? observed.rname,
461 ttl: desired.ttl ?? observed.ttl,
462});
463
464const soaDiffers = (
465 desired: ZoneDnsSoa,
466 observed: ZoneDnsSettingsSnapshot["soa"],
467): boolean =>
468 (desired.expire !== undefined && desired.expire !== observed.expire) ||
469 (desired.minTtl !== undefined && desired.minTtl !== observed.minTtl) ||
470 (desired.mname !== undefined && desired.mname !== observed.mname) ||
471 (desired.refresh !== undefined && desired.refresh !== observed.refresh) ||
472 (desired.retry !== undefined && desired.retry !== observed.retry) ||
473 (desired.rname !== undefined && desired.rname !== observed.rname) ||
474 (desired.ttl !== undefined && desired.ttl !== observed.ttl);
475
476const nameserversDiffer = (
477 desired: ZoneDnsNameservers,
478 observed: ZoneDnsSettingsSnapshot["nameservers"],
479): boolean =>
480 desired.type !== observed.type ||
481 (desired.nsSet !== undefined && desired.nsSet !== observed.nsSet);
482
483/**
484 * Build the PATCH body converging observed → desired for the fields the
485 * user declared. Returns `undefined` when nothing differs.
486 */
487const computeDelta = (
488 news: ZoneDnsSettingsProps,
489 observed: ZoneDnsSettingsSnapshot,
490): PatchBody | undefined => {
491 const body: PatchBody = {};
492 let dirty = false;
493 if (
494 news.flattenAllCnames !== undefined &&
495 news.flattenAllCnames !== observed.flattenAllCnames
496 ) {
497 body.flattenAllCnames = news.flattenAllCnames;
498 dirty = true;
499 }
500 if (
501 news.foundationDns !== undefined &&
502 news.foundationDns !== observed.foundationDns
503 ) {
504 body.foundationDns = news.foundationDns;
505 dirty = true;
506 }
507 if (
508 news.internalDns?.referenceZoneId !== undefined &&
509 news.internalDns.referenceZoneId !== observed.internalDns.referenceZoneId
510 ) {
511 body.internalDns = { referenceZoneId: news.internalDns.referenceZoneId };
512 dirty = true;
513 }
514 if (
515 news.multiProvider !== undefined &&
516 news.multiProvider !== observed.multiProvider
517 ) {
518 body.multiProvider = news.multiProvider;
519 dirty = true;
520 }
521 if (
522 news.nameservers !== undefined &&
523 nameserversDiffer(news.nameservers, observed.nameservers)
524 ) {
525 body.nameservers = {
526 type: news.nameservers.type,
527 nsSet: news.nameservers.nsSet,
528 };
529 dirty = true;
530 }
531 if (news.nsTtl !== undefined && news.nsTtl !== observed.nsTtl) {
532 body.nsTtl = news.nsTtl;
533 dirty = true;
534 }
535 if (
536 news.secondaryOverrides !== undefined &&
537 news.secondaryOverrides !== observed.secondaryOverrides
538 ) {
539 body.secondaryOverrides = news.secondaryOverrides;
540 dirty = true;
541 }
542 if (news.soa !== undefined && soaDiffers(news.soa, observed.soa)) {
543 body.soa = mergedSoa(news.soa, observed.soa);
544 dirty = true;
545 }
546 if (news.zoneMode !== undefined && news.zoneMode !== observed.zoneMode) {
547 body.zoneMode = news.zoneMode;
548 dirty = true;
549 }
550 return dirty ? body : undefined;
551};
552
553/**
554 * Build the PATCH body that restores the managed fields to their
555 * pre-management values. Returns `undefined` when nothing drifted.
556 */
557const computeRestore = (
558 managedKeys: ReadonlyArray<string>,
559 initial: ZoneDnsSettingsSnapshot,
560 observed: ZoneDnsSettingsSnapshot,
561): PatchBody | undefined => {
562 const body: PatchBody = {};
563 let dirty = false;
564 for (const key of managedKeys) {
565 switch (key) {
566 case "flattenAllCnames":
567 if (initial.flattenAllCnames !== observed.flattenAllCnames) {
568 body.flattenAllCnames = initial.flattenAllCnames;
569 dirty = true;
570 }
571 break;
572 case "foundationDns":
573 if (initial.foundationDns !== observed.foundationDns) {
574 body.foundationDns = initial.foundationDns;
575 dirty = true;
576 }
577 break;
578 case "internalDns":
579 if (
580 initial.internalDns.referenceZoneId !==
581 observed.internalDns.referenceZoneId &&
582 initial.internalDns.referenceZoneId !== undefined
583 ) {
584 body.internalDns = {
585 referenceZoneId: initial.internalDns.referenceZoneId,
586 };
587 dirty = true;
588 }
589 break;
590 case "multiProvider":
591 if (initial.multiProvider !== observed.multiProvider) {
592 body.multiProvider = initial.multiProvider;
593 dirty = true;
594 }
595 break;
596 case "nameservers":
597 if (
598 initial.nameservers.type !== observed.nameservers.type ||
599 initial.nameservers.nsSet !== observed.nameservers.nsSet
600 ) {
601 body.nameservers = {
602 type: initial.nameservers.type,
603 nsSet: initial.nameservers.nsSet,
604 };
605 dirty = true;
606 }
607 break;
608 case "nsTtl":
609 if (initial.nsTtl !== observed.nsTtl) {
610 body.nsTtl = initial.nsTtl;
611 dirty = true;
612 }
613 break;
614 case "secondaryOverrides":
615 if (initial.secondaryOverrides !== observed.secondaryOverrides) {
616 body.secondaryOverrides = initial.secondaryOverrides;
617 dirty = true;
618 }
619 break;
620 case "soa":
621 if (
622 initial.soa.expire !== observed.soa.expire ||
623 initial.soa.minTtl !== observed.soa.minTtl ||
624 initial.soa.mname !== observed.soa.mname ||
625 initial.soa.refresh !== observed.soa.refresh ||
626 initial.soa.retry !== observed.soa.retry ||
627 initial.soa.rname !== observed.soa.rname ||
628 initial.soa.ttl !== observed.soa.ttl
629 ) {
630 body.soa = {
631 expire: initial.soa.expire,
632 minTtl: initial.soa.minTtl,
633 mname: initial.soa.mname,
634 refresh: initial.soa.refresh,
635 retry: initial.soa.retry,
636 rname: initial.soa.rname,
637 ttl: initial.soa.ttl,
638 };
639 dirty = true;
640 }
641 break;
642 case "zoneMode":
643 if (initial.zoneMode !== observed.zoneMode) {
644 body.zoneMode = initial.zoneMode;
645 dirty = true;
646 }
647 break;
648 default:
649 break;
650 }
651 }
652 return dirty ? body : undefined;
653};