| 1 | import * as customHostnames from "@distilled.cloud/cloudflare/custom-hostnames"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { arrayEqualsUnordered } from "../../Util/equal.ts"; |
| 10 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import { listAllZones } from "../Zone/lookup.ts"; |
| 13 | |
| 14 | /** |
| 15 | * Domain control validation (DCV) method used to prove control over the |
| 16 | * custom hostname before a certificate is issued. |
| 17 | */ |
| 18 | export type DcvMethod = "http" | "txt" | "email"; |
| 19 | |
| 20 | /** |
| 21 | * Certificate authority that issues the managed certificate. |
| 22 | */ |
| 23 | export type CertificateAuthority = |
| 24 | | "digicert" |
| 25 | | "google" |
| 26 | | "lets_encrypt" |
| 27 | | "ssl_com"; |
| 28 | |
| 29 | /** |
| 30 | * Per-hostname TLS settings applied to the edge certificate. |
| 31 | */ |
| 32 | export type SslSettings = { |
| 33 | /** Allowed cipher suites. */ |
| 34 | ciphers?: string[]; |
| 35 | /** Whether Early Hints (HTTP 103) is enabled. */ |
| 36 | earlyHints?: "on" | "off"; |
| 37 | /** Whether HTTP/2 is enabled. */ |
| 38 | http2?: "on" | "off"; |
| 39 | /** Minimum TLS version served for this hostname. */ |
| 40 | minTlsVersion?: "1.0" | "1.1" | "1.2" | "1.3"; |
| 41 | /** Whether TLS 1.3 is enabled. */ |
| 42 | tls_1_3?: "on" | "off"; |
| 43 | }; |
| 44 | |
| 45 | /** |
| 46 | * SSL configuration for a custom hostname's managed certificate. |
| 47 | */ |
| 48 | export type Ssl = { |
| 49 | /** |
| 50 | * Domain control validation method. |
| 51 | * @default "txt" |
| 52 | */ |
| 53 | method?: DcvMethod; |
| 54 | /** |
| 55 | * Level of validation for the certificate. Only domain validation |
| 56 | * (`dv`) is supported. |
| 57 | * @default "dv" |
| 58 | */ |
| 59 | type?: "dv"; |
| 60 | /** |
| 61 | * Certificate authority that issues the certificate. Omit to let |
| 62 | * Cloudflare choose. |
| 63 | */ |
| 64 | certificateAuthority?: CertificateAuthority; |
| 65 | /** |
| 66 | * How the intermediate chain is bundled with the leaf certificate. |
| 67 | */ |
| 68 | bundleMethod?: "ubiquitous" | "optimal" | "force"; |
| 69 | /** |
| 70 | * Whether to add Cloudflare branding to the certificate (adds |
| 71 | * `sni.cloudflaressl.com` as the certificate common name). |
| 72 | */ |
| 73 | cloudflareBranding?: boolean; |
| 74 | /** |
| 75 | * Whether the certificate also covers `*.hostname`. Toggling can |
| 76 | * trigger certificate reissuance, but is still an in-place update. |
| 77 | */ |
| 78 | wildcard?: boolean; |
| 79 | /** |
| 80 | * Bring-your-own leaf certificate (PEM). Requires `customKey`. |
| 81 | */ |
| 82 | customCertificate?: string; |
| 83 | /** |
| 84 | * Private key (PEM) for `customCertificate`. |
| 85 | */ |
| 86 | customKey?: string; |
| 87 | /** |
| 88 | * Identifier of a previously generated custom CSR. |
| 89 | */ |
| 90 | customCsrId?: string; |
| 91 | /** |
| 92 | * Per-hostname TLS settings. |
| 93 | */ |
| 94 | settings?: SslSettings; |
| 95 | }; |
| 96 | |
| 97 | export interface Props { |
| 98 | /** |
| 99 | * Zone the custom hostname is onboarded onto (the SaaS zone). Stable — |
| 100 | * changing the zone triggers replacement. |
| 101 | */ |
| 102 | zoneId: string; |
| 103 | /** |
| 104 | * The customer-owned hostname that will point at your zone via CNAME |
| 105 | * (e.g. `app.customer.com`). |
| 106 | * |
| 107 | * Stable — the hostname is the resource's identity and is not |
| 108 | * patchable; a rename is a delete + create. Declared as plain `string` |
| 109 | * (not `string`) so it is statically knowable inside `diff`. |
| 110 | */ |
| 111 | hostname: string; |
| 112 | /** |
| 113 | * SSL configuration for the managed certificate. Mutable — patched in |
| 114 | * place (note that changing `ssl` can trigger certificate |
| 115 | * reissuance). |
| 116 | * |
| 117 | * @default { method: "txt", type: "dv" } |
| 118 | */ |
| 119 | ssl?: Ssl; |
| 120 | /** |
| 121 | * Unique key/value metadata for this hostname, available to Workers |
| 122 | * via the request. Requires the Enterprise Cloudflare for SaaS |
| 123 | * entitlement — the API rejects it otherwise. |
| 124 | */ |
| 125 | customMetadata?: Record<string, unknown>; |
| 126 | /** |
| 127 | * Origin server to route traffic for this hostname to, overriding the |
| 128 | * zone's fallback origin. Must be a DNS record within the zone. |
| 129 | * Requires the Enterprise Cloudflare for SaaS entitlement. |
| 130 | */ |
| 131 | customOriginServer?: string; |
| 132 | /** |
| 133 | * SNI value sent to `customOriginServer` during the TLS handshake, or |
| 134 | * the literal `:request_host_header:`. Requires the Enterprise |
| 135 | * Cloudflare for SaaS entitlement. |
| 136 | */ |
| 137 | customOriginSni?: string; |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * TXT record the customer must create to prove ownership of the |
| 142 | * hostname (pre-validation). |
| 143 | */ |
| 144 | export interface OwnershipVerification { |
| 145 | /** TXT record name. */ |
| 146 | name: string | undefined; |
| 147 | /** Record type (always `txt`). */ |
| 148 | type: string | undefined; |
| 149 | /** TXT record value. */ |
| 150 | value: string | undefined; |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * HTTP token alternative for ownership verification. |
| 155 | */ |
| 156 | export interface OwnershipVerificationHttp { |
| 157 | /** URL the token must be served from. */ |
| 158 | httpUrl: string | undefined; |
| 159 | /** Token body to serve. */ |
| 160 | httpBody: string | undefined; |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * A DCV record the customer must create/serve for certificate |
| 165 | * validation. |
| 166 | */ |
| 167 | export interface ValidationRecord { |
| 168 | /** CNAME validation record name. */ |
| 169 | cname: string | undefined; |
| 170 | /** CNAME validation record target. */ |
| 171 | cnameTarget: string | undefined; |
| 172 | /** Email addresses validation mail is sent to. */ |
| 173 | emails: string[] | undefined; |
| 174 | /** HTTP validation token body. */ |
| 175 | httpBody: string | undefined; |
| 176 | /** HTTP validation token URL. */ |
| 177 | httpUrl: string | undefined; |
| 178 | /** Validation record status. */ |
| 179 | status: string | undefined; |
| 180 | /** TXT validation record name. */ |
| 181 | txtName: string | undefined; |
| 182 | /** TXT validation record value. */ |
| 183 | txtValue: string | undefined; |
| 184 | } |
| 185 | |
| 186 | export interface Attributes { |
| 187 | /** Cloudflare-assigned custom hostname UUID. */ |
| 188 | customHostnameId: string; |
| 189 | /** Zone that owns this custom hostname. */ |
| 190 | zoneId: string; |
| 191 | /** The customer-owned hostname. */ |
| 192 | hostname: string; |
| 193 | /** |
| 194 | * Activation status of the hostname (`pending`, `active`, …). A |
| 195 | * hostname for a domain whose DNS the customer has not pointed yet |
| 196 | * stays `pending` — activation is asynchronous and not blocked on. |
| 197 | */ |
| 198 | status: string | undefined; |
| 199 | /** Certificate status (`initializing`, `pending_validation`, `active`, …). */ |
| 200 | sslStatus: string | undefined; |
| 201 | /** TXT record the customer must create to verify ownership. */ |
| 202 | ownershipVerification: OwnershipVerification | undefined; |
| 203 | /** HTTP token alternative for ownership verification. */ |
| 204 | ownershipVerificationHttp: OwnershipVerificationHttp | undefined; |
| 205 | /** DCV records the customer must satisfy for certificate issuance. */ |
| 206 | validationRecords: ValidationRecord[] | undefined; |
| 207 | } |
| 208 | |
| 209 | export type CustomHostname = Resource< |
| 210 | "Cloudflare.CustomHostname.CustomHostname", |
| 211 | Props, |
| 212 | Attributes, |
| 213 | never, |
| 214 | Providers |
| 215 | >; |
| 216 | |
| 217 | /** |
| 218 | * A Cloudflare for SaaS custom hostname. |
| 219 | * |
| 220 | * Onboards a customer-owned hostname onto your zone with a managed TLS |
| 221 | * certificate. The customer points their DNS (CNAME) at your zone; |
| 222 | * Cloudflare validates ownership and issues a certificate |
| 223 | * asynchronously. The first 100 custom hostnames are free on any plan. |
| 224 | * |
| 225 | * Traffic for custom hostnames is routed to the zone's |
| 226 | * {@link FallbackOrigin} (or `customOriginServer` with the Enterprise |
| 227 | * entitlement), so a fallback origin should usually be deployed |
| 228 | * alongside. |
| 229 | * |
| 230 | * Safety: when there is no prior state, `read` scans the zone for an |
| 231 | * existing hostname match. Custom hostnames carry no ownership markers, |
| 232 | * so an existing match is reported as `Unowned` and the engine refuses |
| 233 | * to take it over unless `--adopt` (or `adopt(true)`) is set. |
| 234 | * ### Creating a Custom Hostname |
| 235 | * **Example:** Basic custom hostname with TXT validation |
| 236 | * ```typescript |
| 237 | * const hostname = yield* Cloudflare.CustomHostname.CustomHostname("CustomerApp", { |
| 238 | * zoneId: zone.zoneId, |
| 239 | * hostname: "app.customer.com", |
| 240 | * }); |
| 241 | * // Hand these to the customer so they can verify ownership: |
| 242 | * // hostname.ownershipVerification?.name / .value |
| 243 | * ``` |
| 244 | * |
| 245 | * **Example:** HTTP validation with a specific certificate authority |
| 246 | * ```typescript |
| 247 | * yield* Cloudflare.CustomHostname.CustomHostname("CustomerApp", { |
| 248 | * zoneId: zone.zoneId, |
| 249 | * hostname: "app.customer.com", |
| 250 | * ssl: { |
| 251 | * method: "http", |
| 252 | * type: "dv", |
| 253 | * certificateAuthority: "google", |
| 254 | * }, |
| 255 | * }); |
| 256 | * ``` |
| 257 | * |
| 258 | * ### Pairing with a Fallback Origin |
| 259 | * **Example:** Route custom hostname traffic to your origin |
| 260 | * ```typescript |
| 261 | * const record = yield* Cloudflare.DNS.Record("Origin", { |
| 262 | * zoneId: zone.zoneId, |
| 263 | * name: "origin.my-saas.com", |
| 264 | * type: "A", |
| 265 | * content: "203.0.113.1", |
| 266 | * proxied: true, |
| 267 | * }); |
| 268 | * yield* Cloudflare.CustomHostname.FallbackOrigin("Fallback", { |
| 269 | * zoneId: zone.zoneId, |
| 270 | * origin: record.name, |
| 271 | * }); |
| 272 | * yield* Cloudflare.CustomHostname.CustomHostname("CustomerApp", { |
| 273 | * zoneId: zone.zoneId, |
| 274 | * hostname: "app.customer.com", |
| 275 | * }); |
| 276 | * ``` |
| 277 | * |
| 278 | * @resource |
| 279 | * @product Custom Hostnames |
| 280 | * @category Domains & DNS |
| 281 | */ |
| 282 | export const CustomHostname = Resource<CustomHostname>( |
| 283 | "Cloudflare.CustomHostname.CustomHostname", |
| 284 | { aliases: ["Cloudflare.CustomHostname"] }, |
| 285 | ); |
| 286 | |
| 287 | export const isCustomHostname = (value: unknown): value is CustomHostname => |
| 288 | Predicate.hasProperty(value, "Type") && |
| 289 | value.Type === "Cloudflare.CustomHostname.CustomHostname"; |
| 290 | |
| 291 | export const CustomHostnameProvider = () => |
| 292 | Provider.succeed(CustomHostname, { |
| 293 | stables: ["customHostnameId", "zoneId", "hostname"], |
| 294 | |
| 295 | // Custom hostnames are a zone-scoped Cloudflare for SaaS feature. Fan out |
| 296 | // over every zone in the account, exhaustively paginate each zone's custom |
| 297 | // hostnames, and hydrate each into the same Attributes shape `read` |
| 298 | // produces. Zones without the SaaS entitlement reject with the typed |
| 299 | // `SaasQuotaNotAllocated`/`Forbidden` errors — skip them. |
| 300 | list: Effect.fn(function* () { |
| 301 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 302 | const zones = yield* listAllZones(accountId); |
| 303 | const rows = yield* Effect.forEach( |
| 304 | zones, |
| 305 | (zone) => |
| 306 | customHostnames.listCustomHostnames.pages({ zoneId: zone.id }).pipe( |
| 307 | Stream.runCollect, |
| 308 | Effect.map((chunk) => |
| 309 | Array.from(chunk).flatMap((page) => |
| 310 | (page.result ?? []).map((raw): Attributes => |
| 311 | toAttributes(narrowHostname(raw), zone.id), |
| 312 | ), |
| 313 | ), |
| 314 | ), |
| 315 | Effect.catchTag("SaasQuotaNotAllocated", () => Effect.succeed([])), |
| 316 | Effect.catchTag("Forbidden", () => Effect.succeed([])), |
| 317 | ), |
| 318 | { concurrency: 10 }, |
| 319 | ); |
| 320 | return rows.flat(); |
| 321 | }), |
| 322 | |
| 323 | diff: Effect.fn(function* ({ olds = {}, news }) { |
| 324 | const o = olds as Props; |
| 325 | const n = news as Props; |
| 326 | if (o.hostname !== undefined && o.hostname !== n.hostname) { |
| 327 | return { action: "replace" } as const; |
| 328 | } |
| 329 | // zoneId is Input<string>; compare only once both sides are |
| 330 | // concrete strings. |
| 331 | if ( |
| 332 | typeof o.zoneId === "string" && |
| 333 | typeof n.zoneId === "string" && |
| 334 | o.zoneId !== n.zoneId |
| 335 | ) { |
| 336 | return { action: "replace" } as const; |
| 337 | } |
| 338 | }), |
| 339 | |
| 340 | reconcile: Effect.fn(function* ({ news, output }) { |
| 341 | // Inputs have been resolved to concrete strings by Plan. |
| 342 | const zoneId = news.zoneId as string; |
| 343 | const customOriginServer = news.customOriginServer as string | undefined; |
| 344 | const desiredSsl = buildSslBody(news.ssl); |
| 345 | |
| 346 | // 1. Observe by cached id first. |
| 347 | let observed: ObservedHostname | undefined; |
| 348 | if (output?.customHostnameId) { |
| 349 | observed = yield* observeById(zoneId, output.customHostnameId); |
| 350 | } |
| 351 | |
| 352 | // 2. Fall back to scanning the zone for the hostname (unique per |
| 353 | // zone). Ownership has already been verified upstream — `read` |
| 354 | // reports existing hostnames as `Unowned` and the engine gates |
| 355 | // takeover behind the adopt policy before reconcile runs. |
| 356 | if (!observed) { |
| 357 | observed = yield* findByHostname(zoneId, news.hostname); |
| 358 | } |
| 359 | |
| 360 | // 3. Ensure. A create that races another writer (hostname already |
| 361 | // exists) is converged by re-observing; if the hostname still |
| 362 | // cannot be found the original error is re-raised. |
| 363 | let justCreated = false; |
| 364 | if (!observed) { |
| 365 | observed = yield* customHostnames |
| 366 | .createCustomHostname({ |
| 367 | zoneId, |
| 368 | hostname: news.hostname, |
| 369 | ssl: desiredSsl, |
| 370 | customMetadata: news.customMetadata, |
| 371 | }) |
| 372 | .pipe( |
| 373 | Effect.map(narrowHostname), |
| 374 | Effect.catch((originalError) => |
| 375 | Effect.gen(function* () { |
| 376 | const existing = yield* findByHostname( |
| 377 | zoneId, |
| 378 | news.hostname, |
| 379 | ).pipe(Effect.catch(() => Effect.succeed(undefined))); |
| 380 | if (!existing) return yield* Effect.fail(originalError); |
| 381 | return existing; |
| 382 | }), |
| 383 | ), |
| 384 | ); |
| 385 | justCreated = true; |
| 386 | } |
| 387 | |
| 388 | // 4. Sync — diff each mutable aspect against observed cloud state |
| 389 | // and PATCH only the delta. `ssl` is only sent when it actually |
| 390 | // differs because patching it can trigger certificate |
| 391 | // reissuance. |
| 392 | const patch: { |
| 393 | ssl?: ReturnType<typeof buildSslBody>; |
| 394 | customMetadata?: Record<string, unknown>; |
| 395 | customOriginServer?: string; |
| 396 | customOriginSni?: string; |
| 397 | } = {}; |
| 398 | if ( |
| 399 | !justCreated && |
| 400 | news.ssl !== undefined && |
| 401 | !sslEqualsObserved(news.ssl, observed.ssl) |
| 402 | ) { |
| 403 | patch.ssl = desiredSsl; |
| 404 | } |
| 405 | if ( |
| 406 | news.customMetadata !== undefined && |
| 407 | !metadataEquals(news.customMetadata, observed.customMetadata) |
| 408 | ) { |
| 409 | patch.customMetadata = news.customMetadata; |
| 410 | } |
| 411 | if ( |
| 412 | customOriginServer !== undefined && |
| 413 | customOriginServer !== observed.customOriginServer |
| 414 | ) { |
| 415 | patch.customOriginServer = customOriginServer; |
| 416 | } |
| 417 | if ( |
| 418 | news.customOriginSni !== undefined && |
| 419 | news.customOriginSni !== observed.customOriginSni |
| 420 | ) { |
| 421 | patch.customOriginSni = news.customOriginSni; |
| 422 | } |
| 423 | if (Object.keys(patch).length > 0) { |
| 424 | observed = narrowHostname( |
| 425 | yield* customHostnames.patchCustomHostname({ |
| 426 | zoneId, |
| 427 | customHostnameId: observed.id, |
| 428 | ...patch, |
| 429 | }), |
| 430 | ); |
| 431 | } |
| 432 | |
| 433 | // 5. Return fresh attributes. |
| 434 | return toAttributes(observed, zoneId); |
| 435 | }), |
| 436 | |
| 437 | delete: Effect.fn(function* ({ output }) { |
| 438 | yield* customHostnames |
| 439 | .deleteCustomHostname({ |
| 440 | zoneId: output.zoneId, |
| 441 | customHostnameId: output.customHostnameId, |
| 442 | }) |
| 443 | .pipe(Effect.catchTag("CustomHostnameNotFound", () => Effect.void)); |
| 444 | }), |
| 445 | |
| 446 | read: Effect.fn(function* ({ output, olds }) { |
| 447 | // Owned path: we have persisted state (our own id) — refresh it. |
| 448 | if (output?.customHostnameId) { |
| 449 | const observed = yield* observeById( |
| 450 | output.zoneId, |
| 451 | output.customHostnameId, |
| 452 | ); |
| 453 | if (observed) return toAttributes(observed, output.zoneId); |
| 454 | } |
| 455 | // Adoption path: no state of our own, but the hostname may already |
| 456 | // exist on the zone. Custom hostnames carry no ownership markers we |
| 457 | // can inspect, so brand any match `Unowned` — the engine refuses to |
| 458 | // take over unless `adopt` is set. |
| 459 | const zoneId = |
| 460 | output?.zoneId ?? |
| 461 | (typeof olds?.zoneId === "string" ? olds.zoneId : undefined); |
| 462 | const hostname = output?.hostname ?? olds?.hostname; |
| 463 | if (zoneId && hostname) { |
| 464 | const observed = yield* findByHostname(zoneId, hostname); |
| 465 | if (observed) return Unowned(toAttributes(observed, zoneId)); |
| 466 | } |
| 467 | return undefined; |
| 468 | }), |
| 469 | }); |
| 470 | |
| 471 | // --------------------------------------------------------------------------- |
| 472 | // Observation |
| 473 | // --------------------------------------------------------------------------- |
| 474 | |
| 475 | // A missing hostname is reported by the custom-hostnames API with code |
| 476 | // 1436 ("Custom hostname not found") on a 404 envelope — typed in the |
| 477 | // distilled union as `CustomHostnameNotFound`. |
| 478 | const observeById = (zoneId: string, customHostnameId: string) => |
| 479 | customHostnames.getCustomHostname({ zoneId, customHostnameId }).pipe( |
| 480 | Effect.map(narrowHostname), |
| 481 | Effect.catchTag("CustomHostnameNotFound", () => Effect.succeed(undefined)), |
| 482 | ); |
| 483 | |
| 484 | // Locate an existing custom hostname by exact hostname. The API only |
| 485 | // offers a `contain` filter, so an exact client-side match is applied on |
| 486 | // top. |
| 487 | const findByHostname = (zoneId: string, hostname: string) => |
| 488 | customHostnames.listCustomHostnames |
| 489 | .items({ zoneId, hostname: { contain: hostname } }) |
| 490 | .pipe( |
| 491 | Stream.runCollect, |
| 492 | Effect.map((chunk) => |
| 493 | Array.from(chunk).find((h) => h.hostname === hostname), |
| 494 | ), |
| 495 | Effect.map((found) => |
| 496 | found === undefined ? undefined : narrowHostname(found), |
| 497 | ), |
| 498 | ); |
| 499 | |
| 500 | interface ObservedSsl { |
| 501 | readonly status?: string; |
| 502 | readonly method?: string; |
| 503 | readonly type?: string; |
| 504 | readonly wildcard?: boolean; |
| 505 | readonly bundleMethod?: string; |
| 506 | readonly certificateAuthority?: string; |
| 507 | readonly settings?: { |
| 508 | readonly ciphers?: ReadonlyArray<string>; |
| 509 | readonly earlyHints?: string; |
| 510 | readonly http2?: string; |
| 511 | readonly minTlsVersion?: string; |
| 512 | readonly tls_1_3?: string; |
| 513 | }; |
| 514 | readonly validationRecords?: ReadonlyArray<{ |
| 515 | readonly cname?: string; |
| 516 | readonly cnameTarget?: string; |
| 517 | readonly emails?: string[]; |
| 518 | readonly httpBody?: string; |
| 519 | readonly httpUrl?: string; |
| 520 | readonly status?: string; |
| 521 | readonly txtName?: string; |
| 522 | readonly txtValue?: string; |
| 523 | }>; |
| 524 | } |
| 525 | |
| 526 | interface ObservedHostname { |
| 527 | readonly id: string; |
| 528 | readonly hostname: string; |
| 529 | readonly status?: string; |
| 530 | readonly customMetadata?: Record<string, unknown>; |
| 531 | readonly customOriginServer?: string; |
| 532 | readonly customOriginSni?: string; |
| 533 | readonly ownershipVerification?: { |
| 534 | readonly name?: string; |
| 535 | readonly type?: string; |
| 536 | readonly value?: string; |
| 537 | }; |
| 538 | readonly ownershipVerificationHttp?: { |
| 539 | readonly httpBody?: string; |
| 540 | readonly httpUrl?: string; |
| 541 | }; |
| 542 | readonly ssl?: ObservedSsl; |
| 543 | } |
| 544 | |
| 545 | const undef = <T>(v: T | null | undefined): T | undefined => |
| 546 | v == null ? undefined : v; |
| 547 | |
| 548 | type RawHostname = |
| 549 | | customHostnames.GetCustomHostnameResponse |
| 550 | | customHostnames.CreateCustomHostnameResponse |
| 551 | | customHostnames.PatchCustomHostnameResponse |
| 552 | | customHostnames.ListCustomHostnamesResponse["result"][number]; |
| 553 | |
| 554 | const narrowHostname = (raw: RawHostname): ObservedHostname => ({ |
| 555 | id: raw.id, |
| 556 | hostname: raw.hostname, |
| 557 | status: undef(raw.status), |
| 558 | customMetadata: undef(raw.customMetadata), |
| 559 | customOriginServer: undef(raw.customOriginServer), |
| 560 | customOriginSni: undef(raw.customOriginSni), |
| 561 | ownershipVerification: |
| 562 | raw.ownershipVerification == null |
| 563 | ? undefined |
| 564 | : { |
| 565 | name: undef(raw.ownershipVerification.name), |
| 566 | type: undef(raw.ownershipVerification.type), |
| 567 | value: undef(raw.ownershipVerification.value), |
| 568 | }, |
| 569 | ownershipVerificationHttp: |
| 570 | raw.ownershipVerificationHttp == null |
| 571 | ? undefined |
| 572 | : { |
| 573 | httpBody: undef(raw.ownershipVerificationHttp.httpBody), |
| 574 | httpUrl: undef(raw.ownershipVerificationHttp.httpUrl), |
| 575 | }, |
| 576 | ssl: |
| 577 | raw.ssl == null |
| 578 | ? undefined |
| 579 | : { |
| 580 | status: undef(raw.ssl.status), |
| 581 | method: undef(raw.ssl.method), |
| 582 | type: undef(raw.ssl.type), |
| 583 | wildcard: undef(raw.ssl.wildcard), |
| 584 | bundleMethod: undef(raw.ssl.bundleMethod), |
| 585 | certificateAuthority: undef(raw.ssl.certificateAuthority), |
| 586 | settings: |
| 587 | raw.ssl.settings == null |
| 588 | ? undefined |
| 589 | : { |
| 590 | ciphers: undef(raw.ssl.settings.ciphers), |
| 591 | earlyHints: undef(raw.ssl.settings.earlyHints), |
| 592 | http2: undef(raw.ssl.settings.http2), |
| 593 | minTlsVersion: undef(raw.ssl.settings.minTlsVersion), |
| 594 | tls_1_3: undef(raw.ssl.settings.tls_1_3), |
| 595 | }, |
| 596 | validationRecords: |
| 597 | raw.ssl.validationRecords == null |
| 598 | ? undefined |
| 599 | : raw.ssl.validationRecords.map((r) => ({ |
| 600 | cname: undef(r.cname), |
| 601 | cnameTarget: undef(r.cnameTarget), |
| 602 | emails: undef(r.emails), |
| 603 | httpBody: undef(r.httpBody), |
| 604 | httpUrl: undef(r.httpUrl), |
| 605 | status: undef(r.status), |
| 606 | txtName: undef(r.txtName), |
| 607 | txtValue: undef(r.txtValue), |
| 608 | })), |
| 609 | }, |
| 610 | }); |
| 611 | |
| 612 | const toAttributes = ( |
| 613 | observed: ObservedHostname, |
| 614 | zoneId: string, |
| 615 | ): Attributes => ({ |
| 616 | customHostnameId: observed.id, |
| 617 | zoneId, |
| 618 | hostname: observed.hostname, |
| 619 | status: observed.status, |
| 620 | sslStatus: observed.ssl?.status, |
| 621 | ownershipVerification: |
| 622 | observed.ownershipVerification === undefined |
| 623 | ? undefined |
| 624 | : { |
| 625 | name: observed.ownershipVerification.name, |
| 626 | type: observed.ownershipVerification.type, |
| 627 | value: observed.ownershipVerification.value, |
| 628 | }, |
| 629 | ownershipVerificationHttp: |
| 630 | observed.ownershipVerificationHttp === undefined |
| 631 | ? undefined |
| 632 | : { |
| 633 | httpUrl: observed.ownershipVerificationHttp.httpUrl, |
| 634 | httpBody: observed.ownershipVerificationHttp.httpBody, |
| 635 | }, |
| 636 | validationRecords: |
| 637 | observed.ssl?.validationRecords === undefined |
| 638 | ? undefined |
| 639 | : observed.ssl.validationRecords.map((r) => ({ |
| 640 | cname: r.cname, |
| 641 | cnameTarget: r.cnameTarget, |
| 642 | emails: r.emails, |
| 643 | httpBody: r.httpBody, |
| 644 | httpUrl: r.httpUrl, |
| 645 | status: r.status, |
| 646 | txtName: r.txtName, |
| 647 | txtValue: r.txtValue, |
| 648 | })), |
| 649 | }); |
| 650 | |
| 651 | // --------------------------------------------------------------------------- |
| 652 | // Body construction + drift detection |
| 653 | // --------------------------------------------------------------------------- |
| 654 | |
| 655 | const buildSslBody = (ssl: Ssl | undefined) => ({ |
| 656 | method: ssl?.method ?? "txt", |
| 657 | type: ssl?.type ?? ("dv" as const), |
| 658 | certificateAuthority: ssl?.certificateAuthority, |
| 659 | bundleMethod: ssl?.bundleMethod, |
| 660 | cloudflareBranding: ssl?.cloudflareBranding, |
| 661 | wildcard: ssl?.wildcard, |
| 662 | customCertificate: ssl?.customCertificate, |
| 663 | customKey: ssl?.customKey, |
| 664 | customCsrId: ssl?.customCsrId, |
| 665 | settings: ssl?.settings, |
| 666 | }); |
| 667 | |
| 668 | /** |
| 669 | * Compare desired SSL config against observed cloud state, considering |
| 670 | * only fields the user actually set (Cloudflare echoes more fields than |
| 671 | * we manage, with defaults we should not fight). Returns `true` when no |
| 672 | * patch is needed. |
| 673 | */ |
| 674 | const sslEqualsObserved = ( |
| 675 | desired: Ssl, |
| 676 | observed: ObservedSsl | undefined, |
| 677 | ): boolean => { |
| 678 | if (observed === undefined) return false; |
| 679 | if (desired.method !== undefined && desired.method !== observed.method) { |
| 680 | return false; |
| 681 | } |
| 682 | if (desired.type !== undefined && desired.type !== observed.type) { |
| 683 | return false; |
| 684 | } |
| 685 | if ( |
| 686 | desired.wildcard !== undefined && |
| 687 | desired.wildcard !== (observed.wildcard ?? false) |
| 688 | ) { |
| 689 | return false; |
| 690 | } |
| 691 | if ( |
| 692 | desired.bundleMethod !== undefined && |
| 693 | desired.bundleMethod !== observed.bundleMethod |
| 694 | ) { |
| 695 | return false; |
| 696 | } |
| 697 | if ( |
| 698 | desired.certificateAuthority !== undefined && |
| 699 | desired.certificateAuthority !== observed.certificateAuthority |
| 700 | ) { |
| 701 | return false; |
| 702 | } |
| 703 | const s = desired.settings; |
| 704 | if (s !== undefined) { |
| 705 | const o = observed.settings; |
| 706 | if (s.ciphers !== undefined && !arrayEqualsUnordered(s.ciphers, o?.ciphers)) |
| 707 | return false; |
| 708 | if (s.earlyHints !== undefined && s.earlyHints !== o?.earlyHints) |
| 709 | return false; |
| 710 | if (s.http2 !== undefined && s.http2 !== o?.http2) return false; |
| 711 | if (s.minTlsVersion !== undefined && s.minTlsVersion !== o?.minTlsVersion) |
| 712 | return false; |
| 713 | if (s.tls_1_3 !== undefined && s.tls_1_3 !== o?.tls_1_3) return false; |
| 714 | } |
| 715 | return true; |
| 716 | }; |
| 717 | |
| 718 | const metadataEquals = ( |
| 719 | desired: Record<string, unknown>, |
| 720 | observed: Record<string, unknown> | undefined, |
| 721 | ): boolean => { |
| 722 | const o = observed ?? {}; |
| 723 | const dKeys = Object.keys(desired); |
| 724 | const oKeys = Object.keys(o); |
| 725 | if (dKeys.length !== oKeys.length) return false; |
| 726 | for (const k of dKeys) { |
| 727 | if (JSON.stringify(desired[k]) !== JSON.stringify(o[k])) return false; |
| 728 | } |
| 729 | return true; |
| 730 | }; |