1import * as customCertificates from "@distilled.cloud/cloudflare/custom-certificates";
2import crypto from "node:crypto";
3
4import * as Effect from "effect/Effect";
5import * as Predicate from "effect/Predicate";
6import * as Redacted from "effect/Redacted";
7import * as Stream from "effect/Stream";
8
9import { Unowned } from "../../AdoptPolicy.ts";
10import { isResolved } from "../../Diff.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15import { listAllZones } from "../Zone/lookup.ts";
16
17const TypeId = "Cloudflare.CustomCertificate.CustomCertificate" as const;
18type 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 */
28export 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 */
35export 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 */
41export 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 */
55export interface GeoRestrictions {
56 /**
57 * Region label: `us`, `eu`, or `highest_security`.
58 */
59 label: "us" | "eu" | "highest_security";
60}
61
62export 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
135export 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
173export 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 */
248export const CustomCertificate = Resource<CustomCertificate>(TypeId, {
249 aliases: ["Cloudflare.CustomCertificate"],
250});
251
252/**
253 * Returns true if the given value is a CustomCertificate resource.
254 */
255export const isCustomCertificate = (
256 value: unknown,
257): value is CustomCertificate =>
258 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
259
260export 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
455type ObservedCertificate = customCertificates.GetCustomCertificateResponse;
456
457/**
458 * Read a certificate by id, mapping "gone" (`CustomCertificateNotFound`,
459 * HTTP 404) to `undefined`.
460 */
461const 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 */
476const 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 */
496const 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 */
511const 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 */
527const 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
545const 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});