1import * as originCa from "@distilled.cloud/cloudflare/origin-ca-certificates";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { findZoneByName, listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.OriginCaCertificate.OriginCaCertificate" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Signature type requested on the certificate: `origin-rsa` (RSA),
19 * `origin-ecc` (ECDSA), or `keyless-certificate` (for Keyless SSL servers).
20 */
21export type RequestType = "origin-rsa" | "origin-ecc" | "keyless-certificate";
22
23/**
24 * Number of days the certificate should be valid for. Cloudflare only
25 * accepts this fixed set of validity periods.
26 */
27export type Validity = 7 | 30 | 90 | 365 | 730 | 1095 | 5475;
28
29export interface Props {
30 /**
31 * The Certificate Signing Request (CSR) in PEM format (newline-encoded).
32 * The CSR's key is yours; Cloudflare only signs it. Immutable — changing
33 * the CSR triggers a replacement (a new certificate is issued and the old
34 * one is revoked).
35 */
36 csr: string;
37 /**
38 * Hostnames or wildcard names (e.g. `*.example.com`) bound to the
39 * certificate. Hostnames must be fully qualified domain names belonging
40 * to zones on your account. Immutable — changing the hostnames triggers
41 * a replacement.
42 */
43 hostnames: string[];
44 /**
45 * Signature type desired on the certificate: `origin-rsa` (RSA),
46 * `origin-ecc` (ECDSA), or `keyless-certificate` (for Keyless SSL
47 * servers). Immutable — changing the request type triggers a replacement.
48 */
49 requestType: RequestType;
50 /**
51 * The number of days for which the certificate should be valid.
52 * Immutable — changing the validity triggers a replacement.
53 * @default 5475
54 */
55 requestedValidity?: Validity;
56}
57
58export interface Attributes {
59 /**
60 * Cloudflare-assigned identifier of the certificate (a long decimal
61 * serial string). Stable for the lifetime of the certificate.
62 */
63 certificateId: string;
64 /**
65 * The signed Origin CA certificate in PEM format (newline-encoded).
66 * Install this on your origin server alongside the private key that
67 * produced the CSR.
68 */
69 certificate: string;
70 /**
71 * The Certificate Signing Request the certificate was issued for.
72 */
73 csr: string;
74 /**
75 * Hostnames or wildcard names bound to the certificate.
76 */
77 hostnames: string[];
78 /**
79 * Signature type on the certificate.
80 */
81 requestType: RequestType;
82 /**
83 * The number of days the certificate was requested to be valid for.
84 */
85 requestedValidity: number;
86 /**
87 * When the certificate expires.
88 */
89 expiresOn: string | undefined;
90}
91
92export type OriginCaCertificate = Resource<
93 TypeId,
94 Props,
95 Attributes,
96 never,
97 Providers
98>;
99
100/**
101 * A Cloudflare Origin CA certificate — a free certificate signed by
102 * Cloudflare's Origin CA that encrypts traffic between Cloudflare's edge
103 * and your origin server. Origin CA certificates are only trusted by
104 * Cloudflare (not by browsers), so they are used together with proxied
105 * DNS records.
106 *
107 * You supply a CSR (keeping the private key to yourself); Cloudflare signs
108 * it synchronously and returns the certificate PEM. The endpoints are
109 * top-level (`/certificates`) — the zone is implied by the hostnames in the
110 * request, which must belong to zones on your account.
111 *
112 * Certificates are fully immutable: there is no update API, so changing any
113 * property triggers a replacement (a new certificate is issued, then the
114 * old one is revoked). Destroying the resource revokes the certificate.
115 * ### Issuing a certificate
116 * **Example:** RSA certificate for a single hostname
117 * ```typescript
118 * const cert = yield* Cloudflare.OriginCaCertificate.OriginCaCertificate("origin-cert", {
119 * csr: originCsrPem,
120 * hostnames: ["origin.example.com"],
121 * requestType: "origin-rsa",
122 * requestedValidity: 90,
123 * });
124 * ```
125 *
126 * **Example:** Wildcard ECDSA certificate with the default 15-year validity
127 * ```typescript
128 * const cert = yield* Cloudflare.OriginCaCertificate.OriginCaCertificate("wildcard-cert", {
129 * csr: wildcardCsrPem,
130 * hostnames: ["example.com", "*.example.com"],
131 * requestType: "origin-ecc",
132 * });
133 * ```
134 *
135 * ### Using the certificate
136 * **Example:** Install the signed PEM on your origin
137 * ```typescript
138 * // The signed certificate is returned synchronously on create:
139 * const pem = cert.certificate; // "-----BEGIN CERTIFICATE-----\n..."
140 * const expires = cert.expiresOn;
141 * ```
142 *
143 * @see https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/
144 *
145 * @resource
146 * @product Origin CA Certificates
147 * @category SSL/TLS & Certificates
148 */
149export const OriginCaCertificate = Resource<OriginCaCertificate>(TypeId, {
150 aliases: ["Cloudflare.OriginCaCertificate"],
151});
152
153/**
154 * Returns true if the given value is an OriginCaCertificate resource.
155 */
156export const isOriginCaCertificate = (
157 value: unknown,
158): value is OriginCaCertificate =>
159 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
160
161export const OriginCaCertificateProvider = () =>
162 Provider.succeed(OriginCaCertificate, {
163 // Every prop change is a replacement, so all attributes are stable
164 // across (non-existent) updates.
165 stables: [
166 "certificateId",
167 "certificate",
168 "csr",
169 "hostnames",
170 "requestType",
171 "requestedValidity",
172 "expiresOn",
173 ],
174
175 // Origin CA certificates are enumerated per zone (the list endpoint
176 // requires a `zone_id` query param). Fan out across every zone on the
177 // account and flatten. The list response omits the csr and requested
178 // validity, so `toAttributes` fills them with the defaults — matching the
179 // cold/unowned `read` shape. Origin CA endpoints may require the special
180 // Origin CA Key auth; a zone we can't enumerate rejects with `Forbidden`,
181 // which we skip rather than fail (the array stays as complete as the
182 // credentials allow).
183 list: Effect.fn(function* () {
184 const { accountId } = yield* yield* CloudflareEnvironment;
185 const zones = yield* listAllZones(accountId);
186 const rows = yield* Effect.forEach(
187 zones,
188 (zone) =>
189 originCa.listOriginCaCertificates.pages({ zoneId: zone.id }).pipe(
190 Stream.runCollect,
191 Effect.map((chunk) =>
192 Array.from(chunk).flatMap((page) =>
193 (page.result ?? [])
194 .filter((cert) => cert.id != null)
195 .map((cert): Attributes => toAttributes(cert)),
196 ),
197 ),
198 Effect.catchTag("Forbidden", () =>
199 Effect.succeed([] as Attributes[]),
200 ),
201 ),
202 { concurrency: 10 },
203 );
204 return rows.flat();
205 }),
206
207 diff: Effect.fn(function* ({ olds, news }) {
208 const o = olds as Props | undefined;
209 const n = news as Props;
210 // No prior props to compare against — let the engine decide.
211 if (o?.csr === undefined) return undefined;
212 // There is no update API for Origin CA certificates — every change
213 // is a replacement.
214 if (
215 o.csr !== n.csr ||
216 o.requestType !== n.requestType ||
217 (o.requestedValidity ?? DEFAULT_VALIDITY) !==
218 (n.requestedValidity ?? DEFAULT_VALIDITY) ||
219 !sameHostnames(o.hostnames, n.hostnames)
220 ) {
221 return { action: "replace" } as const;
222 }
223 return undefined;
224 }),
225
226 read: Effect.fn(function* ({ output, olds }) {
227 // Owned path: refresh by our persisted certificate id. A revoked
228 // certificate is still returned by GET (with `revokedAt` set) but is
229 // gone for all practical purposes.
230 if (output?.certificateId) {
231 const observed = yield* getCertificate(output.certificateId);
232 return observed
233 ? toAttributes(observed, { ...olds, ...output })
234 : undefined;
235 }
236
237 // Cold read — Origin CA certificates have no name or tags, so the
238 // only identity available is the exact hostname set within the zone
239 // implied by the hostnames. A match cannot be proven to be ours, so
240 // brand it `Unowned` and let the engine gate takeover behind the
241 // adopt policy. Ambiguity (several live certs with the same
242 // hostnames) means no identity at all — report missing and recreate
243 // (certificates are free and revocation is harmless).
244 // Output-valued hostname elements don't survive a `creating`-state
245 // round-trip (they deserialize as null/undefined) — without a concrete
246 // hostname there is no identity to search by.
247 if (typeof olds?.hostnames?.[0] !== "string") return undefined;
248 const matches = yield* findByHostnames(olds.hostnames);
249 if (matches.length !== 1) return undefined;
250 const observed = yield* getCertificate(matches[0].id!);
251 return observed ? Unowned(toAttributes(observed, olds)) : undefined;
252 }),
253
254 reconcile: Effect.fn(function* ({ news, output }) {
255 // Observe — the certificate id cached on `output` is a hint, not a
256 // guarantee: a missing or revoked certificate falls through to
257 // "missing" and ensure re-issues.
258 const observed = output?.certificateId
259 ? yield* getCertificate(output.certificateId)
260 : undefined;
261
262 // Ensure — issue when missing. Issuance is synchronous: the signed
263 // PEM comes back in the create response. Ids are server-assigned, so
264 // there is no AlreadyExists race to tolerate.
265 if (!observed) {
266 const created = yield* originCa.createOriginCaCertificate({
267 csr: news.csr,
268 hostnames: news.hostnames,
269 requestType: news.requestType,
270 requestedValidity: news.requestedValidity ?? DEFAULT_VALIDITY,
271 });
272 return toAttributes(created, news);
273 }
274
275 // Sync — certificates are immutable; any prop change was already
276 // routed to replacement by diff, so there is nothing to converge.
277 // GET omits the csr and requested validity, so fill them from the
278 // desired props (they are guaranteed equal here).
279 return toAttributes(observed, news);
280 }),
281
282 delete: Effect.fn(function* ({ output }) {
283 // Delete = revoke. Idempotent: an unknown id (1101) or an
284 // already-revoked certificate (1014) both mean "gone".
285 //
286 // `CertificateRevocationFailed` (code 1000) is the dual-use generic
287 // "API errors encountered" Cloudflare returns when a revoke blips —
288 // most often during the replace-time GC delete, when the old
289 // certificate is revoked right after its replacement was issued.
290 // Left untyped it surfaced as `UnknownCloudflareError` and aborted the
291 // delete, orphaning the old certificate (it is no longer in state, so
292 // a later `stack.destroy()` cannot reclaim it). Bounded-retry the
293 // transient failure, then read back: a revoked/absent certificate
294 // counts as deleted; a still-live one re-raises so the leak is never
295 // silent.
296 yield* originCa
297 .deleteOriginCaCertificate({ certificateId: output.certificateId })
298 .pipe(
299 Effect.retry({
300 while: (e) => e._tag === "CertificateRevocationFailed",
301 schedule: Schedule.exponential("500 millis"),
302 times: 6,
303 }),
304 Effect.catchTag("CertificateNotFound", () => Effect.void),
305 Effect.catchTag("CertificateAlreadyRevoked", () => Effect.void),
306 Effect.catchTag("CertificateRevocationFailed", (e) =>
307 getCertificate(output.certificateId).pipe(
308 Effect.flatMap((cert) => (cert ? Effect.fail(e) : Effect.void)),
309 ),
310 ),
311 );
312 }),
313 });
314
315const DEFAULT_VALIDITY = 5475;
316
317/**
318 * Read a certificate by id, mapping "gone" to `undefined`. Gone is either
319 * `CertificateNotFound` (Cloudflare error code 1101) or a successful GET of
320 * an already-revoked certificate (`revokedAt` set).
321 */
322const getCertificate = (certificateId: string) =>
323 originCa.getOriginCaCertificate({ certificateId }).pipe(
324 Effect.map((cert) => (cert.revokedAt ? undefined : cert)),
325 Effect.catchTag("CertificateNotFound", () => Effect.succeed(undefined)),
326 );
327
328/**
329 * Find live certificates with exactly the given hostname set. The zone is
330 * implied by the hostnames: resolve the registered zone by walking the
331 * suffixes of the first hostname (wildcard labels stripped), then scan the
332 * zone's certificate list. Revoked certificates drop out of the list
333 * immediately, so every match is live.
334 */
335const findByHostnames = (hostnames: string[]) =>
336 Effect.gen(function* () {
337 const { accountId } = yield* yield* CloudflareEnvironment;
338 const hostname = hostnames[0].replace(/^\*\./, "");
339 const zoneId = yield* resolveZoneIdForHostname(accountId, hostname);
340 if (!zoneId) return [];
341 const certs = yield* originCa.listOriginCaCertificates
342 .items({ zoneId })
343 .pipe(
344 Stream.filter((cert) => sameHostnames([...cert.hostnames], hostnames)),
345 Stream.runCollect,
346 );
347 return [...certs].sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""));
348 });
349
350/**
351 * Resolve the zone id for a hostname by trying each suffix of the hostname
352 * as a zone name (e.g. `a.b.example.com` → `a.b.example.com`,
353 * `b.example.com`, `example.com`). Returns `undefined` when no zone on the
354 * account matches.
355 */
356const resolveZoneIdForHostname = (accountId: string, hostname: string) =>
357 Effect.gen(function* () {
358 const parts = hostname.split(".");
359 for (let i = 0; i < parts.length - 1; i++) {
360 const name = parts.slice(i).join(".");
361 const zone = yield* findZoneByName({ accountId, name });
362 if (zone) return zone.id;
363 }
364 return undefined;
365 });
366
367const sameHostnames = (
368 observed: readonly string[],
369 desired: readonly string[],
370) =>
371 observed.length === desired.length &&
372 [...observed].sort().join(",") === [...desired].sort().join(",");
373
374type CertificateShape = {
375 id?: string | null;
376 certificate?: string | null;
377 csr?: string | null;
378 hostnames: readonly string[];
379 requestType?: string | null;
380 requestedValidity?: number | null;
381 expiresOn?: string | null;
382};
383
384/**
385 * GET/list responses omit the `csr` and `requested_validity` fields, so
386 * fill them from the desired props (reconcile) or prior state (read).
387 */
388const toAttributes = (
389 cert: CertificateShape,
390 fallback?: {
391 csr?: string;
392 requestType?: RequestType;
393 requestedValidity?: number;
394 },
395): Attributes => ({
396 certificateId: cert.id!,
397 certificate: cert.certificate ?? "",
398 csr: cert.csr ?? fallback?.csr ?? "",
399 hostnames: [...cert.hostnames],
400 requestType: (cert.requestType ??
401 fallback?.requestType ??
402 "origin-rsa") as RequestType,
403 requestedValidity:
404 cert.requestedValidity ?? fallback?.requestedValidity ?? DEFAULT_VALIDITY,
405 expiresOn: cert.expiresOn ?? undefined,
406});