1import * as registrar from "@distilled.cloud/cloudflare/registrar";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Registrar.Domain" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * The mutable Cloudflare Registrar settings on a registered domain. These
17 * are the only fields the Registrar API can change — everything else about
18 * a registration (contacts, nameservers, renewal) is managed in the
19 * Cloudflare dashboard.
20 */
21export interface DomainSettings {
22 /**
23 * Whether the registration auto-renews before it expires.
24 */
25 autoRenew?: boolean;
26 /**
27 * Whether the registrar transfer lock is in place (blocks transfers to
28 * another registrar).
29 */
30 locked?: boolean;
31 /**
32 * Whether WHOIS information is redacted.
33 */
34 privacy?: boolean;
35}
36
37export interface DomainProps {
38 /**
39 * The fully-qualified domain name of a domain that is **already
40 * registered** with Cloudflare Registrar on this account (e.g.
41 * `example.com`). Domains cannot be registered or released via the API —
42 * registration happens in the Cloudflare dashboard.
43 *
44 * The domain name is the resource's identity — changing it triggers a
45 * replacement (the old domain's settings are restored to the values they
46 * had before Alchemy managed them).
47 */
48 domainName: string;
49 /**
50 * Whether the registration auto-renews before it expires. Mutable —
51 * updated in place. When omitted, the current value is left untouched.
52 */
53 autoRenew?: boolean;
54 /**
55 * Whether the registrar transfer lock is in place. Mutable — updated in
56 * place. When omitted, the current value is left untouched.
57 */
58 locked?: boolean;
59 /**
60 * Whether WHOIS information is redacted. Mutable — updated in place.
61 * When omitted, the current value is left untouched.
62 */
63 privacy?: boolean;
64}
65
66export interface DomainAttributes {
67 /** The fully-qualified domain name. */
68 domainName: string;
69 /** Account the domain is registered under. */
70 accountId: string;
71 /** Whether the registration auto-renews. */
72 autoRenew: boolean | undefined;
73 /** Whether the registrar transfer lock is in place. */
74 locked: boolean | undefined;
75 /** Whether WHOIS information is redacted. */
76 privacy: boolean | undefined;
77 /** Whether the domain is available to register (always `false` for a registered domain). */
78 available: boolean | undefined;
79 /** Whether the domain can be registered through Cloudflare. */
80 canRegister: boolean | undefined;
81 /** The registrar currently sponsoring the domain (e.g. `Cloudflare`). */
82 currentRegistrar: string | undefined;
83 /** ISO8601 timestamp when the registration expires. */
84 expiresAt: string | undefined;
85 /** ISO8601 timestamp when the registration was created, if reported. */
86 createdAt: string | undefined;
87 /** ISO8601 timestamp when the registration was last updated, if reported. */
88 updatedAt: string | undefined;
89 /** Comma-separated EPP statuses (e.g. `clienttransferprohibited`). */
90 registryStatuses: string | undefined;
91 /** Whether the domain's TLD is supported by Cloudflare Registrar. */
92 supportedTld: boolean | undefined;
93 /**
94 * The settings the domain had before Alchemy first managed it. Restored
95 * on destroy, so deleting the resource puts the registration back the
96 * way it was found — the domain itself is never released.
97 */
98 initialSettings: DomainSettings;
99}
100
101export type Domain = Resource<
102 TypeId,
103 DomainProps,
104 DomainAttributes,
105 never,
106 Providers
107>;
108
109/**
110 * The mutable Cloudflare Registrar settings (`auto_renew`, `locked`,
111 * `privacy`) on a domain that is already registered with Cloudflare
112 * Registrar.
113 *
114 * Domains cannot be registered or released through the API — purchases,
115 * transfers, and renewals happen in the Cloudflare dashboard. This resource
116 * therefore never creates or deletes a registration: reconcile adopts the
117 * existing domain and converges only the settings you declare, and destroy
118 * restores the settings the domain had before Alchemy first managed it
119 * (captured as `initialSettings`). The domain itself always survives a
120 * destroy.
121 *
122 * Settings you omit are left untouched, both during reconcile and during
123 * the restore on destroy.
124 *
125 * Note: updating registrar settings requires an API token with Registrar
126 * write permission; without it the update fails with the typed
127 * `RegistrarUpdateNotAllowed` error.
128 * ### Managing a registered domain
129 * **Example:** Pin auto-renew and the transfer lock
130 * ```typescript
131 * yield* Cloudflare.Registrar.Domain("ApexDomain", {
132 * domainName: "example.com",
133 * autoRenew: true,
134 * locked: true,
135 * });
136 * ```
137 *
138 * **Example:** Enable WHOIS privacy only
139 * ```typescript
140 * // autoRenew and locked are omitted, so they are left untouched.
141 * yield* Cloudflare.Registrar.Domain("ApexDomain", {
142 * domainName: "example.com",
143 * privacy: true,
144 * });
145 * ```
146 *
147 * ### Reading registration state
148 * **Example:** Use the registration expiry downstream
149 * ```typescript
150 * const domain = yield* Cloudflare.Registrar.Domain("ApexDomain", {
151 * domainName: "example.com",
152 * autoRenew: true,
153 * });
154 * // domain.expiresAt, domain.currentRegistrar, domain.registryStatuses, ...
155 * ```
156 *
157 * @see https://developers.cloudflare.com/registrar/
158 *
159 * @resource
160 * @product Registrar
161 * @category Domains & DNS
162 */
163export const Domain = Resource<Domain>(TypeId);
164
165/**
166 * Returns true if the given value is a Domain resource.
167 */
168export const isDomain = (value: unknown): value is Domain =>
169 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
170
171export const DomainProvider = () =>
172 Provider.succeed(Domain, {
173 // `delete` never releases the registration (it only restores settings), so
174 // a domain can never be removed by teardown and would re-appear on every
175 // `nuke` scan. Skip it in account-wide teardown.
176 nuke: { skip: true },
177 stables: ["domainName", "accountId", "initialSettings"],
178
179 diff: Effect.fn(function* ({ olds, news, output }) {
180 if (!isResolved(news)) return undefined;
181 // The domain name is the resource's identity.
182 const oldDomainName = output?.domainName ?? olds?.domainName;
183 if (oldDomainName !== undefined && oldDomainName !== news.domainName) {
184 return { action: "replace" } as const;
185 }
186 return undefined;
187 }),
188
189 read: Effect.fn(function* ({ output, olds }) {
190 const { accountId } = yield* yield* CloudflareEnvironment;
191 const acct = output?.accountId ?? accountId;
192 const domainName = output?.domainName ?? olds?.domainName;
193 if (!domainName) return undefined;
194 const observed = yield* findDomain(acct, domainName);
195 if (!observed) return undefined;
196 // The underlying registration always pre-exists (it can only be
197 // created in the dashboard) — there is no notion of creating it
198 // ourselves, so a cold read adopts freely (never `Unowned`). The
199 // observed settings at adoption time become the `initialSettings`
200 // restored on destroy.
201 const initialSettings =
202 output !== undefined
203 ? output.initialSettings
204 : captureSettings(observed);
205 return toAttributes(domainName, acct, observed, initialSettings);
206 }),
207
208 reconcile: Effect.fn(function* ({ news, output }) {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210 const domainName = news.domainName;
211
212 // 1. Observe — the registration must already exist on the account;
213 // the API cannot create one.
214 const observed = yield* findDomain(accountId, domainName);
215 if (!observed) {
216 return yield* Effect.fail(
217 new Error(
218 `Domain "${domainName}" is not registered with Cloudflare Registrar ` +
219 `on account ${accountId}. Cloudflare.Registrar.Domain only manages ` +
220 `settings on an existing registration — register or transfer the ` +
221 `domain in the Cloudflare dashboard first.`,
222 ),
223 );
224 }
225
226 // 2. Capture — the pre-management settings, restored on destroy.
227 // `output` (including an adoption read) already carries them;
228 // otherwise this is our first touch and the observed settings are
229 // the registration's originals.
230 const initialSettings =
231 output !== undefined
232 ? output.initialSettings
233 : captureSettings(observed);
234
235 // 3. Sync — diff the observed settings against the declared props and
236 // PUT only on a delta. Omitted props are left untouched.
237 const delta = settingsDelta(observed, news);
238 if (delta === undefined) {
239 return toAttributes(domainName, accountId, observed, initialSettings);
240 }
241 yield* registrar.putDomain({ accountId, domainName, ...delta });
242
243 // 4. Return — re-read so attributes reflect live state; registrar
244 // updates can apply asynchronously, so overlay the desired
245 // settings on what we just put.
246 const fresh = (yield* findDomain(accountId, domainName)) ?? observed;
247 return toAttributes(domainName, accountId, fresh, initialSettings, news);
248 }),
249
250 delete: Effect.fn(function* ({ output }) {
251 const { domainName, accountId, initialSettings } = output;
252 // Never release the registration — destroy only restores the
253 // settings the domain had before Alchemy managed it.
254 const observed = yield* findDomain(accountId, domainName);
255 // Domain gone (transferred out / expired out-of-band) — nothing to
256 // restore.
257 if (!observed) return;
258 const delta = settingsDelta(observed, initialSettings);
259 if (delta === undefined) return;
260 yield* registrar.putDomain({ accountId, domainName, ...delta }).pipe(
261 // Lost ownership between the observe and the put — gone is done.
262 Effect.catchTag("RegistrarDomainNotOwned", () => Effect.void),
263 );
264 }),
265
266 // Account-scoped collection: enumerate every domain registered with
267 // Cloudflare Registrar on the account, exhaustively paginated, and
268 // hydrate each into the exact `read` Attributes shape. There is no prior
269 // managed state for an enumerated domain, so — exactly like a cold
270 // adoption read — the observed settings become the `initialSettings`.
271 list: Effect.fn(function* () {
272 const { accountId } = yield* yield* CloudflareEnvironment;
273 return yield* registrar.listDomains.pages({ accountId }).pipe(
274 Stream.runCollect,
275 Effect.map((chunk) =>
276 Array.from(chunk).flatMap((page) =>
277 (page.result ?? [])
278 .filter(
279 (domain): domain is ObservedDomain & { name: string } =>
280 typeof domain.name === "string",
281 )
282 .map((domain) =>
283 toAttributes(
284 domain.name,
285 accountId,
286 domain,
287 captureSettings(domain),
288 ),
289 ),
290 ),
291 ),
292 );
293 }),
294 });
295
296// ---------------------------------------------------------------------------
297// Helpers
298// ---------------------------------------------------------------------------
299
300type ObservedDomain = registrar.ListDomainsResponse["result"][number];
301
302/**
303 * Find a registered domain by name on the account. `getDomain` answers 200
304 * with a minimal availability stub (`{ name, supported_tld }`) for domains
305 * that are *not* registered on the account, so scanning the typed list is
306 * both the reliable existence check and the typed read.
307 */
308const findDomain = (accountId: string, domainName: string) =>
309 registrar.listDomains.items({ accountId }).pipe(
310 Stream.runCollect,
311 Effect.map((chunk) =>
312 Array.from(chunk).find(
313 (domain): domain is ObservedDomain => domain.name === domainName,
314 ),
315 ),
316 );
317
318/** Snapshot the mutable registrar settings of an observed domain. */
319const captureSettings = (observed: ObservedDomain): DomainSettings => ({
320 autoRenew: observed.autoRenew ?? undefined,
321 locked: observed.locked ?? undefined,
322 privacy: observed.privacy ?? undefined,
323});
324
325/**
326 * Diff observed settings against desired ones. Only fields that are
327 * explicitly desired (not `undefined`) participate. Returns `undefined`
328 * when nothing needs to change, so the PUT can be skipped entirely.
329 */
330const settingsDelta = (
331 observed: ObservedDomain,
332 desired: DomainSettings,
333): DomainSettings | undefined => {
334 const delta: DomainSettings = {};
335 let dirty = false;
336 if (
337 desired.autoRenew !== undefined &&
338 desired.autoRenew !== (observed.autoRenew ?? undefined)
339 ) {
340 delta.autoRenew = desired.autoRenew;
341 dirty = true;
342 }
343 if (
344 desired.locked !== undefined &&
345 desired.locked !== (observed.locked ?? undefined)
346 ) {
347 delta.locked = desired.locked;
348 dirty = true;
349 }
350 if (
351 desired.privacy !== undefined &&
352 desired.privacy !== (observed.privacy ?? undefined)
353 ) {
354 delta.privacy = desired.privacy;
355 dirty = true;
356 }
357 return dirty ? delta : undefined;
358};
359
360const toAttributes = (
361 domainName: string,
362 accountId: string,
363 observed: ObservedDomain,
364 initialSettings: DomainSettings,
365 desired?: DomainSettings,
366): DomainAttributes => ({
367 domainName,
368 accountId,
369 // Registrar setting updates can apply asynchronously — overlay the
370 // settings we just PUT over the (possibly lagging) observed values.
371 autoRenew: desired?.autoRenew ?? observed.autoRenew ?? undefined,
372 locked: desired?.locked ?? observed.locked ?? undefined,
373 privacy: desired?.privacy ?? observed.privacy ?? undefined,
374 available: observed.available ?? undefined,
375 canRegister: observed.canRegister ?? undefined,
376 currentRegistrar: observed.currentRegistrar ?? undefined,
377 expiresAt: observed.expiresAt ?? undefined,
378 createdAt: observed.createdAt ?? undefined,
379 updatedAt: observed.updatedAt ?? undefined,
380 registryStatuses: observed.registryStatuses ?? undefined,
381 supportedTld: observed.supportedTld ?? undefined,
382 initialSettings,
383});