1import * as zones from "@distilled.cloud/cloudflare/zones";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { isResolved } from "../../Diff.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.Zone.CustomNameservers" as const;
13type TypeId = typeof TypeId;
14
15export type CustomNameserversProps = {
16 /**
17 * Zone whose account-level custom nameserver usage is managed. Stable —
18 * changing the zone triggers a replacement (the old zone's configuration
19 * is restored to the value it had before Alchemy managed it).
20 */
21 zoneId: string;
22 /**
23 * Whether the zone uses account-level custom nameservers (ACNS) instead
24 * of the Cloudflare-assigned nameservers.
25 *
26 * Enabling requires an account-level custom nameserver set to already be
27 * configured (Business/Enterprise) — otherwise Cloudflare rejects the
28 * update with the typed `CustomNameserverSetNotFound` error.
29 *
30 * Mutable — applied in place.
31 */
32 enabled: boolean;
33 /**
34 * The number of the account custom nameserver set to assign to the zone.
35 * Only meaningful when `enabled` is `true`.
36 *
37 * Mutable — applied in place.
38 *
39 * @default 1 (Cloudflare's default nameserver set)
40 */
41 nsSet?: number;
42};
43
44export type CustomNameserversAttributes = {
45 /** Zone whose custom nameserver usage is managed. */
46 zoneId: string;
47 /** Whether the zone currently uses account-level custom nameservers. */
48 enabled: boolean;
49 /** The nameserver set currently assigned to the zone, if reported. */
50 nsSet: number | undefined;
51 /**
52 * Whether ACNS was enabled before Alchemy first touched the zone.
53 * Restored on destroy, so deleting the resource puts the zone back the
54 * way it was found.
55 */
56 initialEnabled: boolean;
57 /** The nameserver set assigned before Alchemy first touched the zone. */
58 initialNsSet: number | undefined;
59};
60
61export type CustomNameservers = Resource<
62 TypeId,
63 CustomNameserversProps,
64 CustomNameserversAttributes,
65 never,
66 Providers
67>;
68
69/**
70 * Controls whether a Cloudflare zone uses **account-level custom
71 * nameservers** (ACNS, `/zones/{zone_id}/custom_ns`).
72 *
73 * This configuration is a zone singleton — it always exists on every zone
74 * (disabled by default), so the resource never creates or deletes anything
75 * physical. Reconcile applies the desired `enabled`/`nsSet` when the
76 * observed configuration differs; destroy restores the configuration the
77 * zone had before Alchemy first managed it.
78 *
79 * Enabling requires an account custom nameserver set to be configured first
80 * (Business/Enterprise feature). Without one, Cloudflare rejects the update
81 * with the typed `CustomNameserverSetNotFound` error.
82 * ### Enabling account custom nameservers
83 * **Example:** Use the account's default nameserver set
84 * ```typescript
85 * yield* Cloudflare.Zone.CustomNameservers("CustomNs", {
86 * zoneId: zone.zoneId,
87 * enabled: true,
88 * });
89 * ```
90 *
91 * **Example:** Pin a specific nameserver set
92 * ```typescript
93 * yield* Cloudflare.Zone.CustomNameservers("CustomNs", {
94 * zoneId: zone.zoneId,
95 * enabled: true,
96 * nsSet: 2,
97 * });
98 * ```
99 *
100 * ### Disabling
101 * **Example:** Explicitly pin the zone to Cloudflare-assigned nameservers
102 * ```typescript
103 * yield* Cloudflare.Zone.CustomNameservers("CustomNs", {
104 * zoneId: zone.zoneId,
105 * enabled: false,
106 * });
107 * ```
108 *
109 * @see https://developers.cloudflare.com/api/resources/zones/subresources/custom_nameservers/
110 *
111 * @resource
112 * @product Zones
113 * @category Domains & DNS
114 */
115export const CustomNameservers = Resource<CustomNameservers>(TypeId);
116
117/**
118 * Returns true if the given value is a CustomNameservers resource.
119 */
120export const isCustomNameservers = (
121 value: unknown,
122): value is CustomNameservers =>
123 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
124
125export const CustomNameserversProvider = () =>
126 Provider.succeed(CustomNameservers, {
127 stables: ["zoneId", "initialEnabled", "initialNsSet"],
128
129 list: Effect.fn(function* () {
130 const { accountId } = yield* yield* CloudflareEnvironment;
131 // No account-wide API for this zone singleton — enumerate every
132 // zone in the account and read its custom-nameserver config.
133 const allZones = yield* listAllZones(accountId);
134 const rows = yield* Effect.forEach(
135 allZones.map((zone) => zone.id),
136 (zoneId) =>
137 zones.getCustomNameserver({ zoneId }).pipe(
138 Effect.map((observed) => {
139 // Only zones actively using account custom nameservers are
140 // worth enumerating — a disabled (default) toggle has
141 // nothing to restore on destroy.
142 if (!(observed.enabled ?? false)) return undefined;
143 return toAttributes(
144 zoneId,
145 observed,
146 observed.enabled ?? false,
147 observed.nsSet ?? undefined,
148 );
149 }),
150 // ACNS is a Business/Enterprise feature — plan-gated zones
151 // reject with `Forbidden`; zones deleted out-of-band 404 with
152 // `InvalidZoneIdentifier`. Skip both.
153 Effect.catchTag(["InvalidZoneIdentifier", "Forbidden"], () =>
154 Effect.succeed(undefined),
155 ),
156 ),
157 { concurrency: 10 },
158 );
159 return rows.filter(
160 (row): row is CustomNameserversAttributes => row !== undefined,
161 );
162 }),
163
164 diff: Effect.fn(function* ({ olds, news, output }) {
165 // zoneId is the resource's identity; compare only once both sides
166 // are concrete (resolved) values.
167 if (!isResolved(news)) return undefined;
168 const oldZoneId =
169 output?.zoneId ??
170 (olds !== undefined && isResolved(olds) ? olds.zoneId : undefined);
171 if (oldZoneId !== undefined && oldZoneId !== news.zoneId) {
172 return { action: "replace" } as const;
173 }
174 return undefined;
175 }),
176
177 read: Effect.fn(function* ({ output, olds }) {
178 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
179 if (!zoneId) return undefined;
180 const observed = yield* zones.getCustomNameserver({ zoneId }).pipe(
181 // Zone deleted out-of-band — the configuration is gone with it.
182 Effect.catchTag("InvalidZoneIdentifier", () =>
183 Effect.succeed(undefined),
184 ),
185 );
186 if (observed === undefined) return undefined;
187 // The custom-NS toggle is a singleton that always exists (disabled
188 // by default) — there is nothing to "own", so a cold read adopts
189 // freely (never `Unowned`). The observed state at adoption time
190 // becomes the baseline restored on destroy.
191 const initialEnabled =
192 output !== undefined
193 ? output.initialEnabled
194 : (observed.enabled ?? false);
195 const initialNsSet =
196 output !== undefined
197 ? output.initialNsSet
198 : (observed.nsSet ?? undefined);
199 return toAttributes(zoneId, observed, initialEnabled, initialNsSet);
200 }),
201
202 reconcile: Effect.fn(function* ({ news, output }) {
203 // Inputs have been resolved to concrete strings by Plan.
204 const zoneId = news.zoneId as string;
205
206 // 1. Observe — the configuration always exists; read its live state.
207 const observed = yield* zones.getCustomNameserver({ zoneId });
208
209 // 2. Capture — the pre-management state, restored on destroy.
210 // `output` (including an adoption read) already carries it;
211 // otherwise this is our first touch and the observed state is
212 // the zone's original.
213 const initialEnabled =
214 output !== undefined
215 ? output.initialEnabled
216 : (observed.enabled ?? false);
217 const initialNsSet =
218 output !== undefined
219 ? output.initialNsSet
220 : (observed.nsSet ?? undefined);
221
222 // 3. Sync — apply only when the observed state differs.
223 const observedEnabled = observed.enabled ?? false;
224 const observedNsSet = observed.nsSet ?? undefined;
225 const inSync =
226 observedEnabled === news.enabled &&
227 (news.nsSet === undefined || observedNsSet === news.nsSet);
228 if (inSync) {
229 return toAttributes(zoneId, observed, initialEnabled, initialNsSet);
230 }
231 yield* zones.putCustomNameserver({
232 zoneId,
233 enabled: news.enabled,
234 ...(news.nsSet !== undefined ? { nsSet: news.nsSet } : {}),
235 });
236 // The PUT response does not reliably echo the resulting state —
237 // re-read so the attributes reflect the observed cloud state.
238 const final = yield* zones.getCustomNameserver({ zoneId });
239 return toAttributes(zoneId, final, initialEnabled, initialNsSet);
240 }),
241
242 delete: Effect.fn(function* ({ output }) {
243 const { zoneId, initialEnabled, initialNsSet } = output;
244 // Observe — if the zone itself is gone, so is the configuration.
245 const observed = yield* zones
246 .getCustomNameserver({ zoneId })
247 .pipe(
248 Effect.catchTag("InvalidZoneIdentifier", () =>
249 Effect.succeed(undefined),
250 ),
251 );
252 if (observed === undefined) return;
253 // Restore the pre-management state; skip the call when it already
254 // matches (idempotent re-delete after a crashed run).
255 const observedEnabled = observed.enabled ?? false;
256 const observedNsSet = observed.nsSet ?? undefined;
257 if (
258 observedEnabled === initialEnabled &&
259 (initialNsSet === undefined || observedNsSet === initialNsSet)
260 ) {
261 return;
262 }
263 yield* zones
264 .putCustomNameserver({
265 zoneId,
266 enabled: initialEnabled,
267 ...(initialNsSet !== undefined ? { nsSet: initialNsSet } : {}),
268 })
269 .pipe(
270 // Zone deleted out-of-band, or the account nameserver set the
271 // zone originally referenced no longer exists — nothing left to
272 // restore.
273 Effect.catchTag(
274 ["InvalidZoneIdentifier", "CustomNameserverSetNotFound"],
275 () => Effect.void,
276 ),
277 );
278 }),
279 });
280
281const toAttributes = (
282 zoneId: string,
283 observed: zones.GetCustomNameserverResponse,
284 initialEnabled: boolean,
285 initialNsSet: number | undefined,
286): CustomNameserversAttributes => ({
287 zoneId,
288 enabled: observed.enabled ?? false,
289 nsSet: observed.nsSet ?? undefined,
290 initialEnabled,
291 initialNsSet,
292});