1import * as zones from "@distilled.cloud/cloudflare/zones";
2import * as Array from "effect/Array";
3import * as Effect from "effect/Effect";
4import * as Equivalence from "effect/Equivalence";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { stripNullFields } from "../../Util/data.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { findZoneByName } from "./lookup.ts";
14
15export type Type = "full" | "partial" | "secondary" | "internal";
16export type Status = "initializing" | "pending" | "active" | "moved";
17
18/** Metadata about the zone (Cloudflare `meta`). */
19export type Meta = {
20 /** @deprecated Always `false`. */
21 cdnOnly: boolean | undefined;
22 /** Number of allowed custom certificates. */
23 customCertificateQuota: number | undefined;
24 /** @deprecated Always `true`. */
25 dnsOnly: boolean | undefined;
26 /** Whether the zone is on Cloudflare's Foundation DNS plan. */
27 foundationDns: boolean | undefined;
28 /** Number of allowed Page Rules. */
29 pageRuleQuota: number | undefined;
30 /** Whether the zone has been flagged for phishing. */
31 phishingDetected: boolean | undefined;
32 /** Onboarding step the zone is currently on. */
33 step: number | undefined;
34};
35
36/** The owner of the zone (Cloudflare `owner`). */
37export type Owner = {
38 /** Owner identifier. */
39 id: string | undefined;
40 /** Owner name. */
41 name: string | undefined;
42 /** Owner type (e.g. `user`, `organization`). */
43 type: string | undefined;
44};
45
46/** An organizational unit (tenant) the zone belongs to. */
47export type Tenant = {
48 /** Tenant identifier. */
49 id: string | undefined;
50 /** Tenant name. */
51 name: string | undefined;
52};
53
54/** The immediate parent organizational unit of the zone. */
55export type TenantUnit = {
56 /** Tenant unit identifier. */
57 id: string | undefined;
58};
59
60/**
61 * Common shape returned by a managed {@link Zone} resource — anything that
62 * needs to point at a Cloudflare Zone can accept this.
63 *
64 * Mirrors Cloudflare's zone object. `zoneId` and `accountId` are Alchemy's
65 * flattened identifiers (Cloudflare exposes these as `id` and `account.id`);
66 * every other field keeps Cloudflare's own (camelCased) name.
67 */
68export type Attributes = {
69 /** Zone identifier (Cloudflare `id`). Stable across updates. */
70 zoneId: string;
71 /** The fully-qualified domain name (e.g. `example.com`). */
72 name: string;
73 /** Identifier of the account the zone belongs to (Cloudflare `account.id`). */
74 accountId: string;
75 /** Name of the account the zone belongs to (Cloudflare `account.name`). */
76 accountName: string | undefined;
77 /**
78 * Zone type. A full zone hosts its DNS at Cloudflare; a partial zone is a
79 * partner/CNAME setup.
80 */
81 type: Type;
82 /** The zone status on Cloudflare. */
83 status: Status | undefined;
84 /**
85 * Whether the zone is DNS-only (Cloudflare's proxy/security features
86 * disabled).
87 */
88 paused: boolean;
89 /** The name servers Cloudflare assigns to the zone. */
90 nameServers: string[];
91 /** The original name servers before the domain moved to Cloudflare. */
92 originalNameServers: string[] | undefined;
93 /** Custom (vanity) name servers. Business/Enterprise plans only. */
94 vanityNameServers: string[] | undefined;
95 /** The last time proof of ownership was detected and the zone activated. */
96 activatedOn: string | undefined;
97 /** When the zone was created. */
98 createdOn: string;
99 /**
100 * The interval (in seconds) until development mode expires (positive) or
101 * since it last expired (negative). `0` if never enabled.
102 */
103 developmentMode: number;
104 /** When the zone was last modified. */
105 modifiedOn: string;
106 /** The DNS host at the time of switching to Cloudflare. */
107 originalDnshost: string | undefined;
108 /** The registrar for the domain at the time of switching to Cloudflare. */
109 originalRegistrar: string | undefined;
110 /** Allows the customer to use a custom apex (tenants-only configuration). */
111 cnameSuffix: string | undefined;
112 /** Verification key for partial zone setup. */
113 verificationKey: string | undefined;
114 /** Metadata about the zone. */
115 meta: Meta;
116 /** The owner of the zone. */
117 owner: Owner;
118 /** The root organizational unit (tenant) the zone belongs to. */
119 tenant: Tenant | undefined;
120 /** The immediate parent organizational unit of the zone. */
121 tenantUnit: TenantUnit | undefined;
122};
123
124export type Props = {
125 /**
126 * The fully-qualified zone name (e.g. `example.com`). Stable — changing it
127 * triggers a replacement.
128 */
129 name: string;
130 /**
131 * Zone type. Full zones host their own DNS at Cloudflare; partial zones are
132 * partner/CNAME setups.
133 * @default "full"
134 */
135 type?: Type;
136 /**
137 * Pause Cloudflare's proxy on the zone (DNS-only).
138 * @default false
139 */
140 paused?: boolean;
141 /**
142 * Custom (vanity) name servers. Business/Enterprise only.
143 */
144 vanityNameServers?: string[];
145};
146
147export type Zone = Resource<
148 "Cloudflare.Zone.Zone",
149 Props,
150 Attributes,
151 never,
152 Providers
153>;
154
155/**
156 * A Cloudflare Zone (DNS domain) managed by Alchemy.
157 *
158 * Zones default to **retain** on removal — destroying the stack does NOT
159 * delete the zone in Cloudflare. Opt in to actual deletion by wrapping the
160 * resource (or the whole stack) in {@link destroy}() from
161 * `alchemy/RemovalPolicy`.
162 * ### Creating a Zone
163 * **Example:** Create a new zone
164 * ```typescript
165 * const zone = yield* Cloudflare.Zone.Zone("MyZone", {
166 * name: "example.com",
167 * });
168 * ```
169 *
170 * **Example:** Allow destruction
171 * ```typescript
172 * import { destroy } from "alchemy/RemovalPolicy";
173 * yield* Cloudflare.Zone.Zone("MyZone", { name: "example.com" }).pipe(destroy());
174 * ```
175 *
176 * ### Adopting an existing Zone
177 * **Example:** Take over a zone that already exists in Cloudflare
178 * ```typescript
179 * import { adopt } from "alchemy/AdoptPolicy";
180 * // A zone carries no ownership markers, so the engine refuses to take over a
181 * // pre-existing zone unless you opt in with `adopt(true)`.
182 * const zone = yield* Cloudflare.Zone.Zone("MyZone", {
183 * name: "example.com",
184 * }).pipe(adopt(true));
185 * // zone.zoneId, zone.nameServers, zone.accountId, ...
186 * ```
187 *
188 * @resource
189 * @product Zones
190 * @category Domains & DNS
191 */
192export const Zone = Resource<Zone>("Cloudflare.Zone.Zone", {
193 defaultRemovalPolicy: "retain",
194 aliases: ["Cloudflare.Zone"],
195});
196
197export const ZoneProvider = () =>
198 Provider.effect(
199 Zone,
200 Effect.gen(function* () {
201 // const get = yield* zones.getZone;
202 // const create = yield* zones.createZone;
203 // const patch = yield* zones.patchZone;
204 // const del = yield* zones.deleteZone;
205
206 return {
207 stables: ["name", "zoneId", "accountId"],
208 diff: Effect.fn(function* ({ news, output }) {
209 if (!output) return undefined;
210 if (!isResolved(news)) return undefined;
211 if (news.name !== output.name) {
212 return { action: "replace" } as const;
213 }
214 const desiredType = news.type ?? "full";
215 const desiredPaused = news.paused ?? false;
216 const desiredVanity = news.vanityNameServers ?? [];
217 if (
218 desiredType !== output.type ||
219 desiredPaused !== output.paused ||
220 !stringArrayEq(desiredVanity, output.vanityNameServers ?? [])
221 ) {
222 return { action: "update" } as const;
223 }
224 return undefined;
225 }),
226 read: Effect.fn(function* ({ output, olds }) {
227 const { accountId } = yield* yield* CloudflareEnvironment;
228 const name = output?.name ?? olds?.name;
229 // Owned path: we have persisted state (our own zoneId) — refresh it.
230 if (output?.zoneId) {
231 const result = yield* zones
232 .getZone({ zoneId: output.zoneId })
233 .pipe(Effect.catch(() => Effect.succeed(undefined)));
234 if (result) return toZoneAttributes(result, accountId);
235 }
236 // Adoption path: no state of our own, but a zone with this name
237 // already exists in the cloud. Cloudflare zones carry no ownership
238 // markers we can inspect, so we cannot prove we created it — brand
239 // it `Unowned` so the engine refuses to take over unless `adopt` is
240 // set.
241 if (name) {
242 const match = yield* findZoneByName({ accountId, name });
243 if (!match) return undefined;
244 const result = yield* zones.getZone({ zoneId: match.id });
245 return Unowned(toZoneAttributes(result, accountId));
246 }
247 return undefined;
248 }),
249 reconcile: Effect.fn(function* ({ news, output }) {
250 const { accountId } = yield* yield* CloudflareEnvironment;
251 // 1. Observe — do we have a live zone for this name?
252 let zoneId = output?.zoneId;
253 if (!zoneId) {
254 const match = yield* findZoneByName({
255 accountId,
256 name: news.name,
257 });
258 zoneId = match?.id;
259 }
260
261 // 2. Ensure — create if missing.
262 if (!zoneId) {
263 zoneId = yield* zones
264 .createZone({
265 account: { id: accountId },
266 name: news.name,
267 type: news.type ?? "full",
268 })
269 .pipe(
270 Effect.map((created) => created.id),
271 // A concurrent deploy (or a prior crashed run) may have created
272 // the zone between our observe and create — recover by resolving
273 // its id rather than failing the reconcile.
274 Effect.catchTag("ZoneAlreadyExists", () =>
275 findZoneByName({ accountId, name: news.name }).pipe(
276 Effect.flatMap((match) =>
277 match
278 ? Effect.succeed(match.id)
279 : Effect.fail(
280 new Error(
281 `Cloudflare reported zone ${news.name} already exists but it could not be found`,
282 ),
283 ),
284 ),
285 ),
286 ),
287 );
288 }
289
290 // 3. Sync — apply mutable settings (type/paused/vanity NS).
291 const observed = yield* zones.getZone({ zoneId });
292 const desiredType = news.type ?? "full";
293 const desiredPaused = news.paused ?? false;
294 const desiredVanity = news.vanityNameServers ?? [];
295 const needsPatch =
296 (observed.type ?? "full") !== desiredType ||
297 (observed.paused ?? false) !== desiredPaused ||
298 !stringArrayEq(observed.vanityNameServers ?? [], desiredVanity);
299
300 if (needsPatch) {
301 yield* zones.patchZone({
302 zoneId,
303 type: desiredType,
304 paused: desiredPaused,
305 vanityNameServers: desiredVanity,
306 });
307 }
308
309 const final = yield* zones.getZone({ zoneId });
310 return toZoneAttributes(final, accountId);
311 }),
312 delete: Effect.fn(function* ({ output }) {
313 if (!output.zoneId) return;
314 yield* zones.deleteZone({ zoneId: output.zoneId }).pipe(
315 // Zone already gone — idempotent delete.
316 Effect.catchTag("InvalidZoneIdentifier", () => Effect.void),
317 );
318 }),
319 list: () =>
320 Effect.gen(function* () {
321 const { accountId } = yield* yield* CloudflareEnvironment;
322 // Enumerate every zone in the account, paginating exhaustively.
323 const zoneIds = yield* zones.listZones
324 .pages({ account: { id: accountId } })
325 .pipe(
326 Stream.runCollect,
327 Effect.map((chunk) =>
328 Array.fromIterable(chunk).flatMap((page) =>
329 (page.result ?? []).map((zone) => zone.id),
330 ),
331 ),
332 );
333 // Hydrate each into the exact `read` Attributes shape via getZone,
334 // tolerating zones that vanish mid-enumeration.
335 const rows = yield* Effect.forEach(
336 zoneIds,
337 (zoneId) =>
338 zones.getZone({ zoneId }).pipe(
339 Effect.map((result) => toZoneAttributes(result, accountId)),
340 Effect.catchTag("InvalidZoneIdentifier", () =>
341 Effect.succeed(undefined),
342 ),
343 ),
344 { concurrency: 10 },
345 );
346 return rows.filter((row): row is Attributes => row !== undefined);
347 }),
348 };
349 }),
350 );
351
352/** @internal — shape a distilled zones API result into `Attributes`. */
353export const toZoneAttributes = (
354 result: zones.GetZoneResponse,
355 fallbackAccountId: string,
356): Attributes => {
357 // Cloudflare returns `null` for absent fields; drop them so every optional
358 // attribute is simply `undefined` (matching `Attributes`).
359 const z = stripNullFields(result);
360 return {
361 zoneId: z.id,
362 name: z.name,
363 accountId: z.account.id ?? fallbackAccountId,
364 accountName: z.account.name,
365 type: z.type ?? "full",
366 status: z.status,
367 paused: z.paused ?? false,
368 nameServers: z.nameServers,
369 originalNameServers: z.originalNameServers,
370 vanityNameServers: z.vanityNameServers,
371 activatedOn: z.activatedOn,
372 createdOn: z.createdOn,
373 developmentMode: z.developmentMode,
374 modifiedOn: z.modifiedOn,
375 originalDnshost: z.originalDnshost,
376 originalRegistrar: z.originalRegistrar,
377 cnameSuffix: z.cnameSuffix,
378 verificationKey: z.verificationKey,
379 meta: z.meta,
380 owner: z.owner,
381 tenant: z.tenant,
382 tenantUnit: z.tenantUnit,
383 } as Attributes;
384};
385
386const stringArrayEq = Array.makeEquivalence(Equivalence.String);