| 1 | import * as customCertificates from "@distilled.cloud/cloudflare/custom-certificates"; |
| 2 | import crypto from "node:crypto"; |
| 3 | |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Predicate from "effect/Predicate"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | |
| 9 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 10 | import { isResolved } from "../../Diff.ts"; |
| 11 | import * as Provider from "../../Provider.ts"; |
| 12 | import { Resource } from "../../Resource.ts"; |
| 13 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 14 | import type { Providers } from "../Providers.ts"; |
| 15 | import { listAllZones } from "../Zone/lookup.ts"; |
| 16 | |
| 17 | const TypeId = "Cloudflare.CustomCertificate.CustomCertificate" as const; |
| 18 | type TypeId = typeof TypeId; |
| 19 | |
| 20 | /** |
| 21 | * How Cloudflare builds the certificate chain served to clients. |
| 22 | * |
| 23 | * A `ubiquitous` bundle has the highest probability of being verified |
| 24 | * everywhere, even by clients using outdated or unusual trust stores. An |
| 25 | * `optimal` bundle uses the shortest chain and newest intermediates. `force` |
| 26 | * serves exactly the certificate you uploaded. |
| 27 | */ |
| 28 | export type BundleMethod = "ubiquitous" | "optimal" | "force"; |
| 29 | |
| 30 | /** |
| 31 | * SNI support of the uploaded certificate. `legacy_custom` enables support |
| 32 | * for legacy clients which do not include SNI in the TLS handshake (requires |
| 33 | * dedicated IPs); `sni_custom` is the recommended modern option. |
| 34 | */ |
| 35 | export type Type = "legacy_custom" | "sni_custom"; |
| 36 | |
| 37 | /** |
| 38 | * Lifecycle status of a custom certificate. Uploads are asynchronous — |
| 39 | * a certificate transitions `initializing` → `active`. |
| 40 | */ |
| 41 | export type Status = |
| 42 | | "active" |
| 43 | | "expired" |
| 44 | | "deleted" |
| 45 | | "pending" |
| 46 | | "initializing" |
| 47 | // Keep the union open so new Cloudflare statuses aren't blocked by stale |
| 48 | // types. |
| 49 | | (string & {}); |
| 50 | |
| 51 | /** |
| 52 | * Geo Key Manager region restriction: where the certificate's private key |
| 53 | * may be held locally for optimal TLS performance. |
| 54 | */ |
| 55 | export interface GeoRestrictions { |
| 56 | /** |
| 57 | * Region label: `us`, `eu`, or `highest_security`. |
| 58 | */ |
| 59 | label: "us" | "eu" | "highest_security"; |
| 60 | } |
| 61 | |
| 62 | export interface Props { |
| 63 | /** |
| 64 | * Zone the certificate is uploaded to. Custom certificates are a |
| 65 | * zone-level feature (Business and Enterprise plans only). |
| 66 | * |
| 67 | * Immutable — moving a certificate between zones triggers a replacement. |
| 68 | */ |
| 69 | zoneId: string; |
| 70 | /** |
| 71 | * The zone's SSL certificate — the leaf certificate plus any |
| 72 | * intermediates, in PEM format. Plain `string` (not `string`) so |
| 73 | * its content hash is statically computable. |
| 74 | * |
| 75 | * Mutable — Cloudflare PATCHes a new certificate onto the same record, |
| 76 | * keeping the certificate id stable across rotations. |
| 77 | */ |
| 78 | certificate: string; |
| 79 | /** |
| 80 | * The certificate's private key in PEM format. Not required when |
| 81 | * `customCsrId` is provided, in which case the private key is retrieved |
| 82 | * from the CSR record held by Cloudflare. |
| 83 | * |
| 84 | * Write-only — Cloudflare never echoes the key back; a content hash of |
| 85 | * the certificate/key pair is persisted in the attributes for diffing. |
| 86 | */ |
| 87 | privateKey?: Redacted.Redacted<string>; |
| 88 | /** |
| 89 | * The identifier of a Custom CSR held by Cloudflare to source the private |
| 90 | * key from, as an alternative to uploading `privateKey`. |
| 91 | */ |
| 92 | customCsrId?: string; |
| 93 | /** |
| 94 | * How Cloudflare builds the certificate chain served to clients. |
| 95 | * @default "ubiquitous" |
| 96 | */ |
| 97 | bundleMethod?: BundleMethod; |
| 98 | /** |
| 99 | * SNI support: `sni_custom` (recommended) or `legacy_custom` (supports |
| 100 | * non-SNI clients; requires dedicated IPs). |
| 101 | * |
| 102 | * Immutable — the PATCH endpoint does not accept `type`, so changing it |
| 103 | * triggers a replacement. |
| 104 | * @default "legacy_custom" |
| 105 | */ |
| 106 | type?: Type; |
| 107 | /** |
| 108 | * Geo Key Manager region restriction for the private key. Mutually |
| 109 | * exclusive with `policy`. |
| 110 | */ |
| 111 | geoRestrictions?: GeoRestrictions; |
| 112 | /** |
| 113 | * Geo Key Manager policy expression (e.g. |
| 114 | * `(country: US) or (region: EU)`) that determines where the private key |
| 115 | * is held. Mutually exclusive with `geoRestrictions`. Cloudflare echoes |
| 116 | * this back as the `policyRestrictions` attribute. |
| 117 | */ |
| 118 | policy?: string; |
| 119 | /** |
| 120 | * The environment to deploy the certificate to. Staging deploys are an |
| 121 | * Enterprise feature. Write-only — not echoed back by the API. |
| 122 | * @default "production" |
| 123 | */ |
| 124 | deploy?: "staging" | "production"; |
| 125 | /** |
| 126 | * The order/priority in which the certificate is used in a request. A |
| 127 | * higher priority breaks ties across overlapping `legacy_custom` |
| 128 | * certificates. Synced via the prioritize endpoint when it differs from |
| 129 | * the observed value. |
| 130 | * @default API-assigned |
| 131 | */ |
| 132 | priority?: number; |
| 133 | } |
| 134 | |
| 135 | export interface Attributes { |
| 136 | /** Cloudflare-assigned identifier of the custom certificate. Stable across in-place certificate rotations. */ |
| 137 | certificateId: string; |
| 138 | /** Zone the certificate belongs to. */ |
| 139 | zoneId: string; |
| 140 | /** Hostnames covered by the certificate. */ |
| 141 | hosts: string[]; |
| 142 | /** The certificate authority that issued the certificate. */ |
| 143 | issuer: string | undefined; |
| 144 | /** The type of hash used for the certificate signature. */ |
| 145 | signature: string | undefined; |
| 146 | /** ISO8601 date the certificate expires. */ |
| 147 | expiresOn: string | undefined; |
| 148 | /** ISO8601 date the certificate was uploaded to Cloudflare. */ |
| 149 | uploadedOn: string | undefined; |
| 150 | /** ISO8601 date the certificate was last modified. */ |
| 151 | modifiedOn: string | undefined; |
| 152 | /** How Cloudflare builds the certificate chain served to clients. */ |
| 153 | bundleMethod: BundleMethod | undefined; |
| 154 | /** SNI support the certificate was uploaded with. Not echoed by the API — persisted from the input props. */ |
| 155 | type: Type; |
| 156 | /** The order/priority in which the certificate is used in a request. */ |
| 157 | priority: number | undefined; |
| 158 | /** Lifecycle status of the certificate (`initializing` → `active`). */ |
| 159 | status: Status | undefined; |
| 160 | /** Geo Key Manager policy expression, as echoed by the API for the `policy` prop. */ |
| 161 | policyRestrictions: string | undefined; |
| 162 | /** Geo Key Manager region restriction, if set. */ |
| 163 | geoRestrictions: GeoRestrictions | undefined; |
| 164 | /** |
| 165 | * SHA-256 hash of the uploaded certificate/private key pair. Cloudflare |
| 166 | * never echoes the PEM contents back, so this hash is the diff baseline |
| 167 | * for deciding whether to re-push the certificate (a documented exception |
| 168 | * to "observation > assumption"). |
| 169 | */ |
| 170 | contentHash: string; |
| 171 | } |
| 172 | |
| 173 | export type CustomCertificate = Resource< |
| 174 | TypeId, |
| 175 | Props, |
| 176 | Attributes, |
| 177 | never, |
| 178 | Providers |
| 179 | >; |
| 180 | |
| 181 | /** |
| 182 | * A Cloudflare custom (BYO) edge certificate — upload your own SSL |
| 183 | * certificate and private key to be served at Cloudflare's edge for a zone. |
| 184 | * |
| 185 | * Custom certificates are a **Business / Enterprise** feature; on lower |
| 186 | * plans every API call fails with the typed `PlanLevelNotAllowed` error |
| 187 | * (Cloudflare error code 1011). |
| 188 | * |
| 189 | * The certificate id is stable across in-place rotations: PATCHing a new |
| 190 | * `certificate`/`privateKey` pair keeps the same id. Cloudflare never echoes |
| 191 | * the PEM contents back, so a SHA-256 content hash of the pair is persisted |
| 192 | * in the attributes and used as the rotation diff baseline. Only `zoneId` |
| 193 | * and `type` force a replacement. |
| 194 | * ### Uploading a certificate |
| 195 | * **Example:** Basic SNI certificate |
| 196 | * ```typescript |
| 197 | * const cert = yield* Cloudflare.CustomCertificate.CustomCertificate("EdgeCert", { |
| 198 | * zoneId: zone.zoneId, |
| 199 | * certificate: certPem, |
| 200 | * privateKey: Redacted.make(keyPem), |
| 201 | * type: "sni_custom", |
| 202 | * }); |
| 203 | * ``` |
| 204 | * |
| 205 | * **Example:** Optimal bundle with a Geo Key Manager region |
| 206 | * ```typescript |
| 207 | * yield* Cloudflare.CustomCertificate.CustomCertificate("EuCert", { |
| 208 | * zoneId: zone.zoneId, |
| 209 | * certificate: certPem, |
| 210 | * privateKey: Redacted.make(keyPem), |
| 211 | * type: "sni_custom", |
| 212 | * bundleMethod: "optimal", |
| 213 | * geoRestrictions: { label: "eu" }, |
| 214 | * }); |
| 215 | * ``` |
| 216 | * |
| 217 | * ### Rotating the certificate |
| 218 | * **Example:** Rotate in place |
| 219 | * ```typescript |
| 220 | * // Changing `certificate`/`privateKey` PATCHes the same certificate id — |
| 221 | * // no replacement, no coverage gap. |
| 222 | * yield* Cloudflare.CustomCertificate.CustomCertificate("EdgeCert", { |
| 223 | * zoneId: zone.zoneId, |
| 224 | * certificate: renewedCertPem, |
| 225 | * privateKey: Redacted.make(renewedKeyPem), |
| 226 | * type: "sni_custom", |
| 227 | * }); |
| 228 | * ``` |
| 229 | * |
| 230 | * ### Prioritizing overlapping certificates |
| 231 | * **Example:** Explicit priority |
| 232 | * ```typescript |
| 233 | * // Higher priority breaks ties across overlapping legacy_custom certs. |
| 234 | * yield* Cloudflare.CustomCertificate.CustomCertificate("PrimaryCert", { |
| 235 | * zoneId: zone.zoneId, |
| 236 | * certificate: certPem, |
| 237 | * privateKey: Redacted.make(keyPem), |
| 238 | * priority: 1, |
| 239 | * }); |
| 240 | * ``` |
| 241 | * |
| 242 | * @see https://developers.cloudflare.com/ssl/edge-certificates/custom-certificates/ |
| 243 | * |
| 244 | * @resource |
| 245 | * @product Custom Certificates |
| 246 | * @category SSL/TLS & Certificates |
| 247 | */ |
| 248 | export const CustomCertificate = Resource<CustomCertificate>(TypeId, { |
| 249 | aliases: ["Cloudflare.CustomCertificate"], |
| 250 | }); |
| 251 | |
| 252 | /** |
| 253 | * Returns true if the given value is a CustomCertificate resource. |
| 254 | */ |
| 255 | export const isCustomCertificate = ( |
| 256 | value: unknown, |
| 257 | ): value is CustomCertificate => |
| 258 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 259 | |
| 260 | export const CustomCertificateProvider = () => |
| 261 | Provider.succeed(CustomCertificate, { |
| 262 | stables: ["certificateId", "zoneId", "type", "uploadedOn"], |
| 263 | |
| 264 | // Zone-scoped collection: fan out over every zone and exhaustively |
| 265 | // paginate that zone's custom certificates. The PEM contents and the |
| 266 | // uploaded `type` are write-only (never echoed back), so — exactly like |
| 267 | // `read`'s cold adoption path — the unknowable `type`/`contentHash` |
| 268 | // default to `legacy_custom`/`""`. Plan-gated zones reject the route with |
| 269 | // the typed `PlanLevelNotAllowed` (custom certs are Business/Enterprise), |
| 270 | // zones that can't host custom certs (partial/pending/deleted between |
| 271 | // list and read) reject with `ZoneNotFound` ("Cannot find a valid zone"), |
| 272 | // and freshly-scoped tokens may blip `Forbidden`; all three skip the zone |
| 273 | // so enumeration still returns every zone it can read. |
| 274 | list: Effect.fn(function* () { |
| 275 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 276 | const zones = yield* listAllZones(accountId); |
| 277 | const rows = yield* Effect.forEach( |
| 278 | zones, |
| 279 | (zone) => |
| 280 | customCertificates.listCustomCertificates |
| 281 | .pages({ zoneId: zone.id }) |
| 282 | .pipe( |
| 283 | Stream.runCollect, |
| 284 | Effect.map((chunk) => |
| 285 | Array.from(chunk).flatMap((page) => |
| 286 | (page.result ?? []).map((cert): Attributes => |
| 287 | toAttributes(cert, { |
| 288 | type: "legacy_custom", |
| 289 | contentHash: "", |
| 290 | }), |
| 291 | ), |
| 292 | ), |
| 293 | ), |
| 294 | Effect.catchTag( |
| 295 | ["PlanLevelNotAllowed", "ZoneNotFound", "Forbidden"], |
| 296 | () => Effect.succeed([] as Attributes[]), |
| 297 | ), |
| 298 | ), |
| 299 | { concurrency: 10 }, |
| 300 | ); |
| 301 | return rows.flat(); |
| 302 | }), |
| 303 | |
| 304 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 305 | if (!isResolved(news)) return undefined; |
| 306 | // Zone move replaces. zoneId is Input<string>; compare only once |
| 307 | // both sides are concrete. |
| 308 | const oldZone = |
| 309 | output?.zoneId ?? |
| 310 | (olds !== undefined && isResolved(olds) |
| 311 | ? (olds.zoneId as string | undefined) |
| 312 | : undefined); |
| 313 | if ( |
| 314 | typeof oldZone === "string" && |
| 315 | typeof news.zoneId === "string" && |
| 316 | oldZone !== news.zoneId |
| 317 | ) { |
| 318 | return { action: "replace" } as const; |
| 319 | } |
| 320 | // `type` is create-only (the PATCH body does not accept it). |
| 321 | const oldType = |
| 322 | output?.type ?? |
| 323 | (olds !== undefined && isResolved(olds) ? olds.type : undefined) ?? |
| 324 | "legacy_custom"; |
| 325 | if ((news.type ?? "legacy_custom") !== oldType) { |
| 326 | return { action: "replace" } as const; |
| 327 | } |
| 328 | return undefined; |
| 329 | }), |
| 330 | |
| 331 | read: Effect.fn(function* ({ output, olds }) { |
| 332 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 333 | if (zoneId === undefined) return undefined; |
| 334 | |
| 335 | // Owned path: refresh by our persisted certificate id. |
| 336 | if (output?.certificateId) { |
| 337 | const observed = yield* getCertificate(zoneId, output.certificateId); |
| 338 | return observed |
| 339 | ? toAttributes(observed, { |
| 340 | type: output.type, |
| 341 | contentHash: output.contentHash, |
| 342 | }) |
| 343 | : undefined; |
| 344 | } |
| 345 | |
| 346 | // Cold path: state was lost. Custom certificates carry no ownership |
| 347 | // markers and no name, so identify the desired certificate by its |
| 348 | // parsed expiry timestamp (an upload of the same PEM always yields |
| 349 | // the same `expires_on`) and report it as `Unowned` so the engine |
| 350 | // gates takeover behind the adopt policy. |
| 351 | if (olds?.certificate === undefined) return undefined; |
| 352 | const parsed = yield* parseCertificate(olds.certificate); |
| 353 | const observed = yield* findByExpiry(zoneId, parsed.expiresAtMs); |
| 354 | if (observed) { |
| 355 | return Unowned( |
| 356 | toAttributes(observed, { |
| 357 | type: olds.type ?? "legacy_custom", |
| 358 | // The live cert/key contents are unknowable — force one |
| 359 | // convergence PATCH after adoption. |
| 360 | contentHash: "", |
| 361 | }), |
| 362 | ); |
| 363 | } |
| 364 | return undefined; |
| 365 | }), |
| 366 | |
| 367 | reconcile: Effect.fn(function* ({ news, output }) { |
| 368 | // Inputs have been resolved to concrete strings by Plan. |
| 369 | const zoneId = news.zoneId as string; |
| 370 | const customCsrId = news.customCsrId as string | undefined; |
| 371 | const type = news.type ?? output?.type ?? "legacy_custom"; |
| 372 | const desiredHash = yield* hashContent(news); |
| 373 | |
| 374 | // 1. Observe — the certificate id cached on `output` is a hint, not |
| 375 | // a guarantee: a missing certificate falls through to create. |
| 376 | let observed = output?.certificateId |
| 377 | ? yield* getCertificate(zoneId, output.certificateId) |
| 378 | : undefined; |
| 379 | |
| 380 | // 2. Ensure — upload when missing. Certificates have no uniqueness |
| 381 | // constraint on Cloudflare's side, so there is no AlreadyExists |
| 382 | // race to tolerate. |
| 383 | if (!observed) { |
| 384 | observed = yield* customCertificates.createCustomCertificate({ |
| 385 | zoneId, |
| 386 | certificate: news.certificate, |
| 387 | privateKey: news.privateKey |
| 388 | ? Redacted.value(news.privateKey) |
| 389 | : undefined, |
| 390 | customCsrId, |
| 391 | bundleMethod: news.bundleMethod, |
| 392 | type: news.type, |
| 393 | geoRestrictions: news.geoRestrictions, |
| 394 | policy: news.policy, |
| 395 | deploy: news.deploy, |
| 396 | }); |
| 397 | // The freshly uploaded contents are the desired contents. |
| 398 | observed = yield* syncPriority(zoneId, observed, news.priority); |
| 399 | return toAttributes(observed, { type, contentHash: desiredHash }); |
| 400 | } |
| 401 | |
| 402 | // 3. Sync — diff observed cloud state against desired. The PEM |
| 403 | // contents are write-only, so the persisted content hash is the |
| 404 | // baseline for the certificate/key pair; everything else diffs |
| 405 | // against the observed response. |
| 406 | const certDirty = output?.contentHash !== desiredHash; |
| 407 | const bundleDirty = |
| 408 | news.bundleMethod !== undefined && |
| 409 | observed.bundleMethod !== news.bundleMethod; |
| 410 | const geoDirty = |
| 411 | news.geoRestrictions !== undefined && |
| 412 | observed.geoRestrictions?.label !== news.geoRestrictions.label; |
| 413 | const policyDirty = |
| 414 | news.policy !== undefined && |
| 415 | (observed.policyRestrictions ?? undefined) !== news.policy; |
| 416 | |
| 417 | if (certDirty || bundleDirty || geoDirty || policyDirty) { |
| 418 | observed = yield* customCertificates.patchCustomCertificate({ |
| 419 | zoneId, |
| 420 | customCertificateId: observed.id, |
| 421 | ...(certDirty |
| 422 | ? { |
| 423 | certificate: news.certificate, |
| 424 | privateKey: news.privateKey |
| 425 | ? Redacted.value(news.privateKey) |
| 426 | : undefined, |
| 427 | customCsrId, |
| 428 | deploy: news.deploy, |
| 429 | } |
| 430 | : {}), |
| 431 | ...(bundleDirty ? { bundleMethod: news.bundleMethod } : {}), |
| 432 | ...(geoDirty ? { geoRestrictions: news.geoRestrictions } : {}), |
| 433 | ...(policyDirty ? { policy: news.policy } : {}), |
| 434 | }); |
| 435 | } |
| 436 | |
| 437 | observed = yield* syncPriority(zoneId, observed, news.priority); |
| 438 | return toAttributes(observed, { type, contentHash: desiredHash }); |
| 439 | }), |
| 440 | |
| 441 | delete: Effect.fn(function* ({ output }) { |
| 442 | yield* customCertificates |
| 443 | .deleteCustomCertificate({ |
| 444 | zoneId: output.zoneId, |
| 445 | customCertificateId: output.certificateId, |
| 446 | }) |
| 447 | .pipe(Effect.catchTag("CustomCertificateNotFound", () => Effect.void)); |
| 448 | }), |
| 449 | }); |
| 450 | |
| 451 | // --------------------------------------------------------------------------- |
| 452 | // Helpers |
| 453 | // --------------------------------------------------------------------------- |
| 454 | |
| 455 | type ObservedCertificate = customCertificates.GetCustomCertificateResponse; |
| 456 | |
| 457 | /** |
| 458 | * Read a certificate by id, mapping "gone" (`CustomCertificateNotFound`, |
| 459 | * HTTP 404) to `undefined`. |
| 460 | */ |
| 461 | const getCertificate = (zoneId: string, customCertificateId: string) => |
| 462 | customCertificates.getCustomCertificate({ zoneId, customCertificateId }).pipe( |
| 463 | Effect.map((cert): ObservedCertificate | undefined => cert), |
| 464 | Effect.catchTag("CustomCertificateNotFound", () => |
| 465 | Effect.succeed(undefined), |
| 466 | ), |
| 467 | ); |
| 468 | |
| 469 | /** |
| 470 | * Find a certificate in the zone whose `expires_on` matches the desired |
| 471 | * PEM's parsed expiry. Uploading the same certificate always produces the |
| 472 | * same expiry, making it the best identity available (the API neither |
| 473 | * echoes the PEM nor exposes the serial number). If several match, pick the |
| 474 | * oldest upload for determinism. |
| 475 | */ |
| 476 | const findByExpiry = (zoneId: string, expiresAtMs: number) => |
| 477 | customCertificates.listCustomCertificates.items({ zoneId }).pipe( |
| 478 | Stream.runCollect, |
| 479 | Effect.map((chunk) => |
| 480 | Array.from(chunk) |
| 481 | .filter( |
| 482 | (cert) => |
| 483 | cert.expiresOn != null && |
| 484 | Date.parse(cert.expiresOn) === expiresAtMs, |
| 485 | ) |
| 486 | .sort((a, b) => (a.uploadedOn ?? "").localeCompare(b.uploadedOn ?? "")) |
| 487 | .at(0), |
| 488 | ), |
| 489 | ); |
| 490 | |
| 491 | /** |
| 492 | * Parse the leaf certificate of a PEM bundle. Sync CPU-only Node API, |
| 493 | * wrapped in `Effect.try` so a malformed PEM surfaces as a typed failure |
| 494 | * instead of a thrown exception. |
| 495 | */ |
| 496 | const parseCertificate = (pem: string) => |
| 497 | Effect.try({ |
| 498 | try: () => { |
| 499 | const x509 = new crypto.X509Certificate(pem); |
| 500 | return { expiresAtMs: Date.parse(x509.validTo) }; |
| 501 | }, |
| 502 | catch: (cause) => |
| 503 | new Error(`failed to parse certificate PEM: ${cause}`, { cause }), |
| 504 | }); |
| 505 | |
| 506 | /** |
| 507 | * SHA-256 over the write-only inputs (certificate, private key, CSR id). |
| 508 | * Persisted in the attributes as the rotation diff baseline because the API |
| 509 | * never returns the PEM contents. |
| 510 | */ |
| 511 | const hashContent = (news: Props) => |
| 512 | Effect.sync(() => |
| 513 | crypto |
| 514 | .createHash("sha256") |
| 515 | .update(news.certificate) |
| 516 | .update("\n") |
| 517 | .update(news.privateKey ? Redacted.value(news.privateKey) : "") |
| 518 | .update("\n") |
| 519 | .update((news.customCsrId as string | undefined) ?? "") |
| 520 | .digest("hex"), |
| 521 | ); |
| 522 | |
| 523 | /** |
| 524 | * Converge the certificate's priority via the prioritize endpoint when an |
| 525 | * explicit `priority` is desired and differs from the observed value. |
| 526 | */ |
| 527 | const syncPriority = ( |
| 528 | zoneId: string, |
| 529 | observed: ObservedCertificate, |
| 530 | priority: number | undefined, |
| 531 | ) => |
| 532 | Effect.gen(function* () { |
| 533 | if (priority === undefined || observed.priority === priority) { |
| 534 | return observed; |
| 535 | } |
| 536 | yield* customCertificates.putPrioritize({ |
| 537 | zoneId, |
| 538 | certificates: [{ id: observed.id, priority }], |
| 539 | }); |
| 540 | // Re-observe so the returned attributes reflect the final state. |
| 541 | const fresh = yield* getCertificate(zoneId, observed.id); |
| 542 | return fresh ?? observed; |
| 543 | }); |
| 544 | |
| 545 | const toAttributes = ( |
| 546 | cert: ObservedCertificate, |
| 547 | meta: { type: Type; contentHash: string }, |
| 548 | ): Attributes => ({ |
| 549 | certificateId: cert.id, |
| 550 | zoneId: cert.zoneId, |
| 551 | hosts: [...(cert.hosts ?? [])], |
| 552 | issuer: cert.issuer ?? undefined, |
| 553 | signature: cert.signature ?? undefined, |
| 554 | expiresOn: cert.expiresOn ?? undefined, |
| 555 | uploadedOn: cert.uploadedOn ?? undefined, |
| 556 | modifiedOn: cert.modifiedOn ?? undefined, |
| 557 | bundleMethod: |
| 558 | (cert.bundleMethod as BundleMethod | null | undefined) ?? undefined, |
| 559 | type: meta.type, |
| 560 | priority: cert.priority ?? undefined, |
| 561 | status: cert.status ?? undefined, |
| 562 | policyRestrictions: cert.policyRestrictions ?? undefined, |
| 563 | geoRestrictions: cert.geoRestrictions?.label |
| 564 | ? { label: cert.geoRestrictions.label as "us" | "eu" | "highest_security" } |
| 565 | : undefined, |
| 566 | contentHash: meta.contentHash, |
| 567 | }); |