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