| 1 | import * as zones from "@distilled.cloud/cloudflare/zones"; |
| 2 | import * as Array from "effect/Array"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Equivalence from "effect/Equivalence"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { stripNullFields } from "../../Util/data.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import { findZoneByName } from "./lookup.ts"; |
| 14 | |
| 15 | export type Type = "full" | "partial" | "secondary" | "internal"; |
| 16 | export type Status = "initializing" | "pending" | "active" | "moved"; |
| 17 | |
| 18 | /** Metadata about the zone (Cloudflare `meta`). */ |
| 19 | export 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`). */ |
| 37 | export 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. */ |
| 47 | export 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. */ |
| 55 | export 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 | */ |
| 68 | export 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 | |
| 124 | export 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 | |
| 147 | export 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 | */ |
| 192 | export const Zone = Resource<Zone>("Cloudflare.Zone.Zone", { |
| 193 | defaultRemovalPolicy: "retain", |
| 194 | aliases: ["Cloudflare.Zone"], |
| 195 | }); |
| 196 | |
| 197 | export 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`. */ |
| 353 | export 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 | |
| 386 | const stringArrayEq = Array.makeEquivalence(Equivalence.String); |