1import * as customNameservers from "@distilled.cloud/cloudflare/custom-nameservers";
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 { isResolved } from "../../Diff.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.CustomNameserver.CustomNameserver" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Verification status of an account custom nameserver. Deprecated by
18 * Cloudflare but still returned by the API.
19 */
20export type Status = "moved" | "pending" | "verified";
21
22/**
23 * A glue record (A/AAAA) that must be registered at the domain registrar
24 * for the custom nameserver to resolve.
25 */
26export interface Record {
27 /**
28 * The record type of the glue record.
29 */
30 type: "A" | "AAAA" | undefined;
31 /**
32 * The IP address of the glue record.
33 */
34 value: string | undefined;
35}
36
37export interface Props {
38 /**
39 * The FQDN of the nameserver (e.g. `ns1.yourbrand.com`). Must be a
40 * subdomain of a zone active on the same account.
41 *
42 * Immutable — this is the nameserver's identity; changing it triggers a
43 * replacement.
44 */
45 nsName: string;
46 /**
47 * The number of the nameserver set this nameserver belongs to (1–5).
48 *
49 * Immutable — there is no update API; changing it triggers a replacement.
50 * @default 1
51 */
52 nsSet?: number;
53}
54
55export interface Attributes {
56 /**
57 * The FQDN of the nameserver. Also the identifier used to delete it.
58 */
59 nsName: string;
60 /**
61 * The Cloudflare account the nameserver belongs to.
62 */
63 accountId: string;
64 /**
65 * The number of the nameserver set this nameserver belongs to.
66 */
67 nsSet: number | undefined;
68 /**
69 * Verification status of the nameserver (deprecated by Cloudflare but
70 * still returned).
71 */
72 status: Status;
73 /**
74 * A/AAAA glue records to register at the domain registrar so the
75 * nameserver resolves.
76 */
77 dnsRecords: Record[];
78 /**
79 * The zone (on this account) that `nsName` belongs to.
80 */
81 zoneTag: string;
82}
83
84export type CustomNameserver = Resource<
85 TypeId,
86 Props,
87 Attributes,
88 never,
89 Providers
90>;
91
92/**
93 * An account-level custom (vanity) nameserver — e.g. `ns1.yourbrand.com` —
94 * that zones on the account can use instead of their assigned
95 * `*.ns.cloudflare.com` names.
96 *
97 * The nameserver's identity is its `nsName` (FQDN) within the account.
98 * There is no update API: both `nsName` and `nsSet` are immutable, so
99 * changing either triggers a replacement. After creation, Cloudflare
100 * returns the A/AAAA glue records (`dnsRecords`) that must be registered
101 * at the domain registrar.
102 *
103 * Requires the account custom nameservers entitlement (Business/Enterprise
104 * or a paid add-on); on unentitled accounts every API call fails with the
105 * typed `CustomNameserversNotEnabled` error.
106 *
107 * Safety: custom nameservers carry no ownership markers. When there is no
108 * prior state, `read` scans the account for an existing nameserver with
109 * the same `nsName` and reports it as `Unowned`, so the engine refuses to
110 * take it over unless `--adopt` (or `adopt(true)`) is set.
111 * ### Creating a custom nameserver
112 * **Example:** Vanity nameserver on the default set
113 * ```typescript
114 * const ns1 = yield* Cloudflare.CustomNameserver.CustomNameserver("Ns1", {
115 * nsName: "ns1.yourbrand.com",
116 * });
117 *
118 * // Glue records to register at your registrar:
119 * const glue = ns1.dnsRecords; // [{ type: "A", value: "..." }, ...]
120 * ```
121 *
122 * **Example:** Nameserver on a specific set
123 * ```typescript
124 * yield* Cloudflare.CustomNameserver.CustomNameserver("Ns2", {
125 * nsName: "ns2.yourbrand.com",
126 * nsSet: 2,
127 * });
128 * ```
129 *
130 * @see https://developers.cloudflare.com/dns/nameservers/custom-nameservers/account-custom-nameservers/
131 *
132 * @resource
133 * @product Custom Nameservers
134 * @category Domains & DNS
135 */
136export const CustomNameserver = Resource<CustomNameserver>(TypeId, {
137 aliases: ["Cloudflare.CustomNameserver"],
138});
139
140/**
141 * Returns true if the given value is a CustomNameserver resource.
142 */
143export const isCustomNameserver = (value: unknown): value is CustomNameserver =>
144 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
145
146export const CustomNameserverProvider = () =>
147 Provider.succeed(CustomNameserver, {
148 stables: ["nsName", "accountId", "nsSet", "zoneTag"],
149
150 list: Effect.fn(function* () {
151 const { accountId } = yield* yield* CloudflareEnvironment;
152 // Account collection: `getCustomNameserver` is a paginated list
153 // endpoint despite its name. Enumerate every page and hydrate each
154 // nameserver into the exact `read` Attributes shape.
155 return yield* customNameservers.getCustomNameserver
156 .pages({ accountId })
157 .pipe(
158 Stream.runCollect,
159 Effect.map((chunk) =>
160 Array.from(chunk).flatMap((page) =>
161 page.result.map((ns) => toAttributes(ns, accountId)),
162 ),
163 ),
164 // Unentitled accounts have no custom nameservers at all — the
165 // collection endpoint rejects with a typed entitlement error.
166 // Treat that as an empty collection rather than failing `list`.
167 Effect.catchTag("CustomNameserversNotEnabled", () =>
168 Effect.succeed([] as Attributes[]),
169 ),
170 );
171 }),
172
173 diff: Effect.fn(function* ({ olds, news }) {
174 if (!isResolved(news)) return undefined;
175 // No prior props to compare against — let the engine decide.
176 if (olds?.nsName === undefined) return undefined;
177 // There is no update API: both the FQDN and the set number are
178 // immutable, so any change is a replacement.
179 if (olds.nsName !== news.nsName) {
180 return { action: "replace" } as const;
181 }
182 if ((olds.nsSet ?? 1) !== (news.nsSet ?? 1)) {
183 return { action: "replace" } as const;
184 }
185 return undefined;
186 }),
187
188 read: Effect.fn(function* ({ output, olds }) {
189 const { accountId } = yield* yield* CloudflareEnvironment;
190 const acct = output?.accountId ?? accountId;
191 const nsName = output?.nsName ?? olds?.nsName;
192 if (nsName === undefined) return undefined;
193
194 const observed = yield* findByName(acct, nsName);
195 if (!observed) return undefined;
196
197 // Owned path: we created this nameserver — refresh its attributes.
198 if (output?.nsName) return toAttributes(observed, acct);
199
200 // Adoption path: a nameserver with this FQDN already exists on the
201 // account. Custom nameservers carry no ownership markers, so brand
202 // it `Unowned` — the engine refuses to take over unless `adopt` is
203 // set.
204 return Unowned(toAttributes(observed, acct));
205 }),
206
207 reconcile: Effect.fn(function* ({ news, output }) {
208 const { accountId } = yield* yield* CloudflareEnvironment;
209
210 // 1. Observe — `nsName` is the nameserver's identity within the
211 // account; the cached `output` is at most a hint. The list is
212 // authoritative: a missing nameserver falls through to create.
213 const observed = yield* findByName(
214 output?.accountId ?? accountId,
215 news.nsName,
216 );
217 if (observed) {
218 // 2. Sync — nothing is mutable (no update API); `diff` already
219 // replaces on nsName/nsSet changes, so observed state is final.
220 return toAttributes(observed, output?.accountId ?? accountId);
221 }
222
223 // 3. Ensure — create when missing. A concurrent create surfaces as
224 // `CustomNameserverAlreadyExists`: converge by re-listing for
225 // the nameserver that won the race.
226 const created = yield* customNameservers
227 .createCustomNameserver({
228 accountId,
229 nsName: news.nsName,
230 nsSet: news.nsSet,
231 })
232 .pipe(
233 Effect.catchTag("CustomNameserverAlreadyExists", (error) =>
234 findByName(accountId, news.nsName).pipe(
235 Effect.flatMap((existing) =>
236 existing ? Effect.succeed(existing) : Effect.fail(error),
237 ),
238 ),
239 ),
240 );
241 return toAttributes(created, accountId);
242 }),
243
244 delete: Effect.fn(function* ({ output }) {
245 // Observe first — DELETE for an already-gone nameserver may not
246 // return a decodable envelope, and a second destroy must be a
247 // no-op either way.
248 const observed = yield* findByName(output.accountId, output.nsName);
249 if (!observed) return;
250 yield* customNameservers
251 .deleteCustomNameserver({
252 accountId: output.accountId,
253 customNsId: output.nsName,
254 })
255 .pipe(Effect.catchTag("CustomNameserverNotFound", () => Effect.void));
256 }),
257 });
258
259type ObservedNameserver =
260 customNameservers.GetCustomNameserverResponse["result"][number];
261
262/**
263 * Find a custom nameserver by exact FQDN on the account. The FQDN is the
264 * nameserver's identity — Cloudflare rejects duplicates — so at most one
265 * can match. `getCustomNameserver` is a list endpoint despite its name.
266 */
267const findByName = (accountId: string, nsName: string) =>
268 customNameservers
269 .getCustomNameserver({ accountId })
270 .pipe(
271 Effect.map((page) =>
272 page.result.find(
273 (ns): ns is ObservedNameserver => ns.nsName === nsName,
274 ),
275 ),
276 );
277
278const toAttributes = (
279 ns: ObservedNameserver | customNameservers.CreateCustomNameserverResponse,
280 accountId: string,
281): Attributes => ({
282 nsName: ns.nsName,
283 accountId,
284 nsSet: ns.nsSet ?? undefined,
285 // Distilled widens generated string enums to open unions (`string & {}`).
286 status: ns.status as Status,
287 dnsRecords: ns.dnsRecords.map((record) => ({
288 type: (record.type ?? undefined) as "A" | "AAAA" | undefined,
289 value: record.value ?? undefined,
290 })),
291 zoneTag: ns.zoneTag,
292});