1import * as ssl from "@distilled.cloud/cloudflare/ssl";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.Ssl.CertificatePack" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Certificate Authorities available for Advanced Certificate Manager orders.
19 *
20 * - `google` — Google Trust Services
21 * - `lets_encrypt` — Let's Encrypt (no `cloudflareBranding`)
22 * - `ssl_com` — SSL.com (supports `email` validation)
23 */
24export type CertificatePackCertificateAuthority =
25 | "google"
26 | "lets_encrypt"
27 | "ssl_com";
28
29/**
30 * Domain Control Validation method used to prove ownership of the
31 * certificate's hostnames.
32 */
33export type CertificatePackValidationMethod = "txt" | "http" | "email";
34
35/**
36 * Number of days the issued certificates are valid for.
37 */
38export type CertificatePackValidityDays = 14 | 30 | 90 | 365;
39
40/**
41 * Status of a certificate pack as reported by Cloudflare.
42 */
43export type CertificatePackStatus =
44 | "initializing"
45 | "pending_validation"
46 | "deleted"
47 | "pending_issuance"
48 | "pending_deployment"
49 | "pending_deletion"
50 | "pending_expiration"
51 | "expired"
52 | "active"
53 | "initializing_timed_out"
54 | "validation_timed_out"
55 | "issuance_timed_out"
56 | "deployment_timed_out"
57 | "deletion_timed_out"
58 | "pending_cleanup"
59 | "staging_deployment"
60 | "staging_active"
61 | "deactivating"
62 | "inactive"
63 | "backup_issued"
64 | "holding_deployment";
65
66/**
67 * A Domain Control Validation record for a certificate pack — create the
68 * indicated TXT/CNAME record (or serve the HTTP token) to complete
69 * validation when the pack is `pending_validation`.
70 */
71export interface CertificatePackValidationRecord {
72 /** Name of the TXT record to create (for `txt` validation). */
73 txtName?: string;
74 /** Value of the TXT record (for `txt` validation). */
75 txtValue?: string;
76 /** URL that must serve `httpBody` (for `http` validation). */
77 httpUrl?: string;
78 /** Body the `httpUrl` must respond with (for `http` validation). */
79 httpBody?: string;
80 /** Name of the CNAME record to create (for delegated DCV). */
81 cname?: string;
82 /** Target of the CNAME record (for delegated DCV). */
83 cnameTarget?: string;
84 /** Approver email addresses (for `email` validation). */
85 emails?: string[];
86 /** Validation status of this record. */
87 status?: string;
88}
89
90export interface CertificatePackProps {
91 /**
92 * Zone the certificate pack belongs to. Stable — moving a pack to a
93 * different zone triggers a replacement.
94 */
95 zoneId: string;
96 /**
97 * Certificate Authority to order the certificates from.
98 *
99 * Immutable — the Cloudflare API has no way to change the CA of an
100 * existing pack, so changing it triggers a replacement.
101 */
102 certificateAuthority: CertificatePackCertificateAuthority;
103 /**
104 * Hostnames the certificates cover. Must contain the zone apex, may
105 * contain wildcards, and may not exceed 50 hosts.
106 *
107 * Immutable — hosts cannot be added to or removed from an existing
108 * pack, so changing them (order-insensitively) triggers a replacement.
109 */
110 hosts: string[];
111 /**
112 * Domain Control Validation method for the order.
113 *
114 * Mutable — changed in place on the existing pack via the SSL
115 * verification API (`PATCH /ssl/verification/{certificatePackId}`).
116 */
117 validationMethod: CertificatePackValidationMethod;
118 /**
119 * Validity period of the issued certificates, in days.
120 *
121 * Immutable — changing it triggers a replacement (re-order).
122 */
123 validityDays: CertificatePackValidityDays;
124 /**
125 * Whether to add Cloudflare branding to the order: a subdomain of
126 * `sni.cloudflaressl.com` is used as the certificate's Common Name.
127 *
128 * Mutable — patched in place.
129 *
130 * @default false
131 */
132 cloudflareBranding?: boolean;
133}
134
135export interface CertificatePackAttributes {
136 /** Cloudflare-assigned identifier of the certificate pack. */
137 certificatePackId: string;
138 /** Zone the pack belongs to. */
139 zoneId: string;
140 /**
141 * Current status of the pack. Issuance is asynchronous — a freshly
142 * ordered pack starts in `initializing`/`pending_validation` and only
143 * reaches `active` once Domain Control Validation completes.
144 */
145 status: CertificatePackStatus;
146 /** Hostnames the certificates cover. */
147 hosts: string[];
148 /** Certificate Authority the pack was ordered from. */
149 certificateAuthority: string;
150 /** Domain Control Validation method currently configured. */
151 validationMethod: string | undefined;
152 /** Validity period of the issued certificates, in days. */
153 validityDays: number | undefined;
154 /** Identifier of the primary certificate in the pack, once issued. */
155 primaryCertificate: string | undefined;
156 /**
157 * Outstanding Domain Control Validation records — create these
158 * TXT/CNAME records (or serve the HTTP tokens) to complete validation.
159 */
160 validationRecords: CertificatePackValidationRecord[] | undefined;
161 /** DCV Delegation records for delegated domain validation. */
162 dcvDelegationRecords: CertificatePackValidationRecord[] | undefined;
163}
164
165export type CertificatePack = Resource<
166 TypeId,
167 CertificatePackProps,
168 CertificatePackAttributes,
169 never,
170 Providers
171>;
172
173/**
174 * An Advanced Certificate Manager (ACM) certificate pack — an order for
175 * edge certificates covering a custom set of hostnames in a zone, with a
176 * choice of Certificate Authority, validation method, and validity period.
177 *
178 * Requires the **Advanced Certificate Manager** subscription on the zone;
179 * ordering without it fails with the typed `AdvancedCertificateManagerRequired`
180 * error (Cloudflare code 1450).
181 *
182 * Issuance is asynchronous: the resource returns as soon as the order is
183 * placed (status `initializing`/`pending_validation`) and does not wait for
184 * `active`, because validation may require you to create DNS records first —
185 * the outstanding records are exported as `validationRecords`.
186 *
187 * The pack's `certificateAuthority`, `hosts`, and `validityDays` are
188 * immutable — changing any of them replaces the pack (a new order).
189 * `validationMethod` and `cloudflareBranding` are updated in place.
190 * ### Ordering a certificate pack
191 * **Example:** Order an advanced certificate for the apex and a wildcard
192 * ```typescript
193 * const pack = yield* Cloudflare.Ssl.CertificatePack("ApexCert", {
194 * zoneId: zone.zoneId,
195 * certificateAuthority: "google",
196 * hosts: ["example.com", "*.example.com"],
197 * validationMethod: "txt",
198 * validityDays: 90,
199 * });
200 * ```
201 *
202 * **Example:** Order from Let's Encrypt with a short validity
203 * ```typescript
204 * yield* Cloudflare.Ssl.CertificatePack("ShortLivedCert", {
205 * zoneId: zone.zoneId,
206 * certificateAuthority: "lets_encrypt",
207 * hosts: ["example.com", "api.example.com"],
208 * validationMethod: "http",
209 * validityDays: 30,
210 * });
211 * ```
212 *
213 * ### Completing validation
214 * **Example:** Create the DCV TXT records the order asks for
215 * ```typescript
216 * const pack = yield* Cloudflare.Ssl.CertificatePack("ApexCert", {
217 * zoneId: zone.zoneId,
218 * certificateAuthority: "google",
219 * hosts: ["example.com"],
220 * validationMethod: "txt",
221 * validityDays: 90,
222 * });
223 * // pack.validationRecords contains the txtName/txtValue pairs to create
224 * // as DNS records so the CA can validate domain control.
225 * ```
226 *
227 * @see https://developers.cloudflare.com/ssl/edge-certificates/advanced-certificate-manager/
228 *
229 * @resource
230 * @product SSL/TLS
231 * @category SSL/TLS & Certificates
232 */
233export const CertificatePack = Resource<CertificatePack>(TypeId);
234
235/**
236 * Returns true if the given value is a CertificatePack resource.
237 */
238export const isCertificatePack = (value: unknown): value is CertificatePack =>
239 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
240
241export const CertificatePackProvider = () =>
242 Provider.succeed(CertificatePack, {
243 stables: ["certificatePackId", "zoneId"],
244
245 diff: Effect.fn(function* ({ olds, news, output }) {
246 // news is Input<Props> during plan — only compare once resolved.
247 if (!isResolved(news)) return undefined;
248 // No prior props to compare against — let the engine decide.
249 if (olds?.hosts === undefined) return undefined;
250 // zoneId is the pack's scope; it is Input<string>, so compare only
251 // once both sides are concrete.
252 const oldZoneId =
253 output?.zoneId ??
254 (typeof olds.zoneId === "string" ? olds.zoneId : undefined);
255 if (
256 oldZoneId !== undefined &&
257 typeof news.zoneId === "string" &&
258 oldZoneId !== news.zoneId
259 ) {
260 return { action: "replace" } as const;
261 }
262 // The order is immutable in CA, hosts, and validity — any change is
263 // a re-order (replacement). hosts compare order-insensitively.
264 if (
265 olds.certificateAuthority !== news.certificateAuthority ||
266 olds.validityDays !== news.validityDays ||
267 !sameHosts(olds.hosts, news.hosts)
268 ) {
269 return { action: "replace" } as const;
270 }
271 // validationMethod / cloudflareBranding are in-place updates.
272 return undefined;
273 }),
274
275 read: Effect.fn(function* ({ output, olds }) {
276 const zoneId =
277 output?.zoneId ??
278 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
279 if (!zoneId) return undefined;
280
281 // Owned path: refresh by our persisted pack id.
282 if (output?.certificatePackId) {
283 const observed = yield* getPack(zoneId, output.certificatePackId);
284 if (observed) return toAttributes(zoneId, observed);
285 return undefined;
286 }
287
288 // Cold lookup (state persistence failed before we saved the id): an
289 // advanced pack with this exact host set may already exist. Packs
290 // carry no ownership markers, so report it as `Unowned` and let the
291 // engine gate takeover behind the adopt policy.
292 if (olds?.hosts) {
293 const observed = yield* findByHosts(zoneId, olds.hosts);
294 if (observed) return Unowned(toAttributes(zoneId, observed));
295 }
296 return undefined;
297 }),
298
299 reconcile: Effect.fn(function* ({ news, output }) {
300 // Inputs have been resolved to concrete strings by Plan.
301 const zoneId = news.zoneId as string;
302
303 // 1. Observe — the pack id cached on `output` is a hint, not a
304 // guarantee: a missing pack falls through to the host-set scan
305 // and then to a fresh order.
306 let observed = output?.certificatePackId
307 ? yield* getPack(zoneId, output.certificatePackId)
308 : undefined;
309
310 // 2. Fall back to scanning the zone for an advanced pack with the
311 // same host set (recovers from a crash after order-before-save).
312 if (!observed) {
313 observed = yield* findByHosts(zoneId, news.hosts);
314 }
315
316 // 3. Ensure — order the pack when missing. Ordering is gated on the
317 // Advanced Certificate Manager subscription; the typed
318 // `AdvancedCertificateManagerRequired` error propagates to the
319 // caller untouched.
320 if (!observed) {
321 observed = yield* ssl.createCertificatePack({
322 zoneId,
323 type: "advanced",
324 certificateAuthority: news.certificateAuthority,
325 hosts: news.hosts,
326 validationMethod: news.validationMethod,
327 validityDays: news.validityDays,
328 cloudflareBranding: news.cloudflareBranding,
329 });
330 }
331
332 // 4. Sync — diff each mutable aspect of the observed pack against
333 // the desired props; skip the API entirely on a no-op. A freshly
334 // ordered pack already matches, so both checks fall through.
335 if (
336 news.cloudflareBranding !== undefined &&
337 (observed.cloudflareBranding ?? false) !== news.cloudflareBranding
338 ) {
339 observed = yield* ssl.patchCertificatePack({
340 zoneId,
341 certificatePackId: observed.id,
342 cloudflareBranding: news.cloudflareBranding,
343 });
344 }
345
346 let validationMethod = observed.validationMethod ?? undefined;
347 if (validationMethod !== news.validationMethod) {
348 const verification = yield* ssl.patchVerification({
349 zoneId,
350 certificatePackId: observed.id,
351 validationMethod: news.validationMethod,
352 });
353 validationMethod = verification.validationMethod ?? validationMethod;
354 }
355
356 return toAttributes(zoneId, observed, validationMethod);
357 }),
358
359 list: Effect.fn(function* () {
360 const { accountId } = yield* yield* CloudflareEnvironment;
361 // Certificate packs are zone-scoped; there is no account-wide
362 // enumeration API. Fan out over every zone in the account and list
363 // its packs, restricting to `advanced` packs (the only kind this
364 // resource manages — universal/total_tls packs are not orderable).
365 const zones = yield* listAllZones(accountId);
366 const rows = yield* Effect.forEach(
367 zones,
368 (zone) =>
369 ssl.listCertificatePacks
370 .pages({ zoneId: zone.id, status: "all" })
371 .pipe(
372 Stream.runCollect,
373 Effect.map((chunk) =>
374 Array.from(chunk).flatMap((page) =>
375 (page.result ?? [])
376 .filter((pack) => pack.type === "advanced")
377 .map((pack) => toAttributes(zone.id, pack)),
378 ),
379 ),
380 // A plan-gated zone (no ACM) rejects the route, and a freshly
381 // minted token may briefly answer Forbidden — skip either.
382 Effect.catchTag(["InvalidRoute", "Forbidden"], () =>
383 Effect.succeed([] as CertificatePackAttributes[]),
384 ),
385 ),
386 { concurrency: 10 },
387 );
388 return rows.flat();
389 }),
390
391 delete: Effect.fn(function* ({ output }) {
392 // Observe first — deleting an already-gone pack answers 404 (code
393 // 1408); treat missing as done so delete stays idempotent.
394 const observed = yield* getPack(output.zoneId, output.certificatePackId);
395 if (!observed) return;
396 yield* ssl
397 .deleteCertificatePack({
398 zoneId: output.zoneId,
399 certificatePackId: output.certificatePackId,
400 })
401 .pipe(Effect.catchTag("CertificatePackNotFound", () => Effect.void));
402 }),
403 });
404
405// ---------------------------------------------------------------------------
406// API helpers
407// ---------------------------------------------------------------------------
408
409type ObservedPack =
410 | ssl.GetCertificatePackResponse
411 | ssl.CreateCertificatePackResponse
412 | ssl.PatchCertificatePackResponse
413 | ssl.ListCertificatePacksResponse["result"][number];
414
415/**
416 * Read a pack by id, mapping "gone" (`CertificatePackNotFound`, Cloudflare
417 * error code 1408 / HTTP 404) and a deleted zone (`InvalidRoute`, code
418 * 7003) to `undefined`.
419 */
420const getPack = (zoneId: string, certificatePackId: string) =>
421 ssl.getCertificatePack({ zoneId, certificatePackId }).pipe(
422 Effect.map((pack): ssl.GetCertificatePackResponse | undefined => pack),
423 Effect.catchTag(["CertificatePackNotFound", "InvalidRoute"], () =>
424 Effect.succeed(undefined),
425 ),
426 );
427
428/**
429 * Find an advanced pack by exact host set within the zone. Cloudflare
430 * rejects a second advanced order for an identical host set, so at most
431 * one pack can match. Includes non-active (pending) packs.
432 */
433const findByHosts = (zoneId: string, hosts: string[]) =>
434 ssl.listCertificatePacks.items({ zoneId, status: "all" }).pipe(
435 Stream.runCollect,
436 Effect.map((chunk) =>
437 Array.from(chunk).find(
438 (pack) => pack.type === "advanced" && sameHosts(pack.hosts, hosts),
439 ),
440 ),
441 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
442 );
443
444/** Order-insensitive host set comparison. */
445const sameHosts = (
446 a: ReadonlyArray<string>,
447 b: ReadonlyArray<string>,
448): boolean => {
449 if (a.length !== b.length) return false;
450 const set = new Set(a);
451 return b.every((host) => set.has(host));
452};
453
454const toRecord = (record: {
455 cname?: string | null;
456 cnameTarget?: string | null;
457 emails?: string[] | null;
458 httpBody?: string | null;
459 httpUrl?: string | null;
460 status?: string | null;
461 txtName?: string | null;
462 txtValue?: string | null;
463}): CertificatePackValidationRecord => ({
464 txtName: record.txtName ?? undefined,
465 txtValue: record.txtValue ?? undefined,
466 httpUrl: record.httpUrl ?? undefined,
467 httpBody: record.httpBody ?? undefined,
468 cname: record.cname ?? undefined,
469 cnameTarget: record.cnameTarget ?? undefined,
470 emails: record.emails ?? undefined,
471 status: record.status ?? undefined,
472});
473
474const toAttributes = (
475 zoneId: string,
476 pack: ObservedPack,
477 validationMethod?: string,
478): CertificatePackAttributes => ({
479 certificatePackId: pack.id,
480 zoneId,
481 status: pack.status as CertificatePackStatus,
482 hosts: [...pack.hosts],
483 certificateAuthority: pack.certificateAuthority ?? "",
484 validationMethod: validationMethod ?? pack.validationMethod ?? undefined,
485 validityDays: pack.validityDays ?? undefined,
486 primaryCertificate: pack.primaryCertificate ?? undefined,
487 validationRecords: pack.validationRecords?.map(toRecord) ?? undefined,
488 dcvDelegationRecords: pack.dcvDelegationRecords?.map(toRecord) ?? undefined,
489});