1import * as originTls from "@distilled.cloud/cloudflare/origin-tls-client-auth";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15const TypeId = "Cloudflare.OriginTlsClientAuth.Certificate" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Deployment status of the certificate. Deploying and deleting are
20 * asynchronous (`pending_deployment` → `active`, `pending_deletion` →
21 * `deleted`), typically settling within minutes.
22 */
23export type CertificateStatus =
24 | "initializing"
25 | "pending_deployment"
26 | "pending_deletion"
27 | "active"
28 | "deleted"
29 | "deployment_timed_out"
30 | "deletion_timed_out"
31 // Keep the union open so new Cloudflare statuses aren't blocked by stale types.
32 | (string & {});
33
34export type CertificateProps = {
35 /**
36 * Zone the certificate is uploaded to. Cannot be changed after upload —
37 * updating this property triggers a replacement.
38 */
39 zoneId: string;
40 /**
41 * The zone's leaf client certificate in PEM format, presented by
42 * Cloudflare to your origin when Authenticated Origin Pulls is enabled.
43 * Cannot be changed after upload — updating this property triggers a
44 * replacement.
45 */
46 certificate: string;
47 /**
48 * The certificate's private key in PEM format. Cannot be changed after
49 * upload — updating this property triggers a replacement.
50 */
51 privateKey: Redacted.Redacted<string>;
52};
53
54export type CertificateAttributes = {
55 /** Unique identifier of the uploaded certificate. */
56 certificateId: string;
57 /** Zone the certificate is uploaded to. */
58 zoneId: string;
59 /** Deployment status of the certificate. */
60 status: CertificateStatus | undefined;
61 /** When the certificate expires. */
62 expiresOn: string | undefined;
63 /** The certificate authority that issued the certificate. */
64 issuer: string | undefined;
65 /** The type of hash used for the certificate signature. */
66 signature: string | undefined;
67 /** When the certificate was uploaded to Cloudflare. */
68 uploadedOn: string | undefined;
69};
70
71export type Certificate = Resource<
72 TypeId,
73 CertificateProps,
74 CertificateAttributes,
75 never,
76 Providers
77>;
78
79/**
80 * A zone-level Authenticated Origin Pulls (AOP) client certificate
81 * (`/zones/{zone_id}/origin_tls_client_auth`).
82 *
83 * Uploads the client certificate Cloudflare presents to your origin when
84 * zone-level Authenticated Origin Pulls is enabled
85 * ({@link Setting}), letting the origin verify that
86 * requests really come from Cloudflare via mTLS.
87 *
88 * Certificates are immutable: there is no update API, so changing any
89 * property triggers a replacement. Deployment is asynchronous — the
90 * certificate starts in `pending_deployment` and becomes `active` within a
91 * few minutes; deletion likewise passes through `pending_deletion`.
92 * ### Uploading a certificate
93 * **Example:** Zone client certificate
94 * ```typescript
95 * const cert = yield* Cloudflare.OriginTlsClientAuth.Certificate("AopCert", {
96 * zoneId: zone.zoneId,
97 * certificate: clientCertPem,
98 * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"),
99 * });
100 * ```
101 *
102 * ### Enabling Authenticated Origin Pulls
103 * **Example:** Upload the certificate and turn AOP on
104 * ```typescript
105 * const cert = yield* Cloudflare.OriginTlsClientAuth.Certificate("AopCert", {
106 * zoneId: zone.zoneId,
107 * certificate: clientCertPem,
108 * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"),
109 * });
110 *
111 * yield* Cloudflare.OriginTlsClientAuth.Setting("Aop", {
112 * zoneId: zone.zoneId,
113 * enabled: true,
114 * });
115 * ```
116 *
117 * @see https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/
118 *
119 * @resource
120 * @product Origin TLS Client Auth
121 * @category SSL/TLS & Certificates
122 */
123export const Certificate = Resource<Certificate>(TypeId);
124
125/**
126 * Returns true if the given value is an Certificate
127 * resource.
128 */
129export const isCertificate = (value: unknown): value is Certificate =>
130 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
131
132export const CertificateProvider = () =>
133 Provider.succeed(Certificate, {
134 stables: ["certificateId", "zoneId"],
135
136 diff: Effect.fn(function* ({ olds = {}, news }) {
137 if (!isResolved(news)) return undefined;
138 const o = olds as CertificateProps;
139 const n = news as CertificateProps;
140 // zoneId is Input<string>; compare only once both sides are concrete.
141 if (
142 typeof o.zoneId === "string" &&
143 typeof n.zoneId === "string" &&
144 o.zoneId !== n.zoneId
145 ) {
146 return { action: "replace" } as const;
147 }
148 if (
149 (o.certificate !== undefined &&
150 normalizePem(o.certificate) !== normalizePem(n.certificate)) ||
151 (o.privateKey !== undefined &&
152 unwrap(o.privateKey) !== unwrap(n.privateKey))
153 ) {
154 // There is no update API for zone client certificates — every change
155 // is a replacement.
156 return { action: "replace" } as const;
157 }
158 return undefined;
159 }),
160
161 read: Effect.fn(function* ({ output, olds }) {
162 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
163 if (!zoneId) return undefined;
164 if (output?.certificateId) {
165 const observed = yield* observeById(zoneId, output.certificateId);
166 if (observed) return toAttributes(observed, zoneId);
167 }
168 // Cold read — recover by listing and matching on the certificate PEM
169 // content (the only identity available; zone client certificates have
170 // no name or tags). A content match IS the desired state, so adoption
171 // is safe without an `Unowned` gate.
172 if (typeof olds?.certificate === "string") {
173 const match = yield* findByContent(zoneId, olds.certificate);
174 if (match) return toAttributes(match, zoneId);
175 }
176 return undefined;
177 }),
178
179 reconcile: Effect.fn(function* ({ news, output }) {
180 // Inputs have been resolved to concrete strings by Plan.
181 const zoneId = news.zoneId as string;
182
183 // 1. Observe — the certificate id is the stable identifier; fall
184 // through a CertificateNotFound (or a pending/completed deletion)
185 // to the list+content match so we recover from out-of-band deletes
186 // and partial state persistence.
187 let observed = output?.certificateId
188 ? yield* observeById(zoneId, output.certificateId)
189 : yield* findByContent(zoneId, news.certificate);
190
191 // 2. Ensure — upload if missing. Cloudflare rejects uploading a PEM
192 // identical to a live certificate (code 1406); tolerate the race
193 // (or an orphaned prior upload) by re-listing and adopting the
194 // certificate with identical PEM content. Re-uploading the PEM of
195 // a certificate in `pending_deletion` resurrects it under the same
196 // id, so a destroy→deploy cycle converges naturally.
197 if (!observed) {
198 observed = yield* originTls
199 .createOriginTlsClientAuth({
200 zoneId,
201 certificate: news.certificate,
202 privateKey: unwrap(news.privateKey),
203 })
204 .pipe(
205 Effect.catchTag("CertificateAlreadyExists", (originalError) =>
206 Effect.gen(function* () {
207 const match = yield* findByContent(zoneId, news.certificate);
208 if (!match) return yield* Effect.fail(originalError);
209 return match;
210 }),
211 ),
212 // A destroy→deploy cycle re-uploading the same PEM hits 1406 while
213 // the prior certificate's tombstone is still `pending_deletion`
214 // (it settles to `deleted` within ~10s, after which the same PEM
215 // either resurrects under its old id or uploads fresh). Ride that
216 // window out with a bounded retry of the whole create-or-adopt
217 // step.
218 //
219 // Cloudflare serializes zone client-cert mutations per zone; when a
220 // sibling upload/delete on the SAME zone is in flight, a concurrent
221 // upload is rejected with HTTP 409 (`ZoneClientCertConflict`).
222 // Retry it on the same bounded (~50s) schedule so concurrent
223 // per-zone mutations serialize gracefully instead of failing the
224 // deploy.
225 Effect.retry({
226 while: (e) =>
227 e._tag === "CertificateAlreadyExists" ||
228 e._tag === "ZoneClientCertConflict",
229 schedule: Schedule.spaced("5 seconds"),
230 times: 10,
231 }),
232 );
233 }
234
235 // 3. Return — deployment is asynchronous (`pending_deployment` →
236 // `active`); we do not block on activation.
237 return toAttributes(observed, zoneId);
238 }),
239
240 list: Effect.fn(function* () {
241 const { accountId } = yield* yield* CloudflareEnvironment;
242 const zones = yield* listAllZones(accountId);
243 const rows = yield* Effect.forEach(
244 zones,
245 (zone) =>
246 originTls.listOriginTlsClientAuths.pages({ zoneId: zone.id }).pipe(
247 Stream.runCollect,
248 Effect.map((chunk) =>
249 Array.from(chunk).flatMap((page) =>
250 (page.result ?? [])
251 // Match `read`: tombstoned (deleted / pending_deletion)
252 // certificates no longer satisfy the desired state.
253 .filter((cert) => isLive(cert.status))
254 .map((cert) => toAttributes(cert, zone.id)),
255 ),
256 ),
257 // A zone the scoped token can't access rejects with the typed
258 // Forbidden tag; skip it rather than failing the whole enumeration.
259 Effect.catchTag("Forbidden", () => Effect.succeed([])),
260 ),
261 { concurrency: 10 },
262 );
263 return rows.flat();
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 // Deletion is idempotent: a certificate that is already gone surfaces
268 // as `CertificateNotFound`, and one already in (or past) deletion
269 // answers HTTP 400 "Certificate is already deleted."
270 // (`CertificateAlreadyDeleted`). Both mean the desired end state — the
271 // certificate is not live — is reached.
272 yield* originTls
273 .deleteOriginTlsClientAuth({
274 zoneId: output.zoneId,
275 certificateId: output.certificateId,
276 })
277 .pipe(
278 // A certificate still propagating its initial deployment answers
279 // code 1434 "Cannot delete resource while in pending deployment
280 // state." — this is why a destroy immediately after a deploy could
281 // leak the certificate. Deployment settles within seconds, so ride
282 // it out with a bounded (~60s) retry.
283 //
284 // Cloudflare serializes zone client-cert mutations per zone; a delete
285 // racing a sibling upload/delete on the SAME zone is rejected with
286 // HTTP 409 (`ZoneClientCertConflict`). Retry it on the same bounded
287 // schedule so the delete (and thus teardown) converges instead of
288 // leaking the certificate.
289 Effect.retry({
290 while: (e) =>
291 e._tag === "CertificatePendingDeployment" ||
292 e._tag === "ZoneClientCertConflict",
293 schedule: Schedule.spaced("5 seconds"),
294 times: 12,
295 }),
296 Effect.catchTag(
297 ["CertificateNotFound", "CertificateAlreadyDeleted"],
298 () => Effect.void,
299 ),
300 );
301 }),
302 });
303
304/** A certificate that is being or has been deleted no longer satisfies the desired state. */
305const isLive = (status: string | null | undefined): boolean =>
306 status !== "deleted" && status !== "pending_deletion";
307
308// Observe a certificate by id, treating "not found" and "deleted /
309// pending_deletion" (Cloudflare keeps tombstones readable by id) as missing.
310const observeById = (zoneId: string, certificateId: string) =>
311 originTls.getOriginTlsClientAuth({ zoneId, certificateId }).pipe(
312 Effect.map((cert) => (isLive(cert.status) ? cert : undefined)),
313 Effect.catchTag("CertificateNotFound", () => Effect.succeed(undefined)),
314 );
315
316// Locate a live certificate by exact PEM content. Tombstoned certificates
317// are excluded so a destroy→deploy cycle re-creates (resurrects) instead of
318// adopting a certificate that is on its way out.
319const findByContent = (zoneId: string, certificate: string) =>
320 Effect.gen(function* () {
321 const list = yield* originTls.listOriginTlsClientAuths({ zoneId });
322 // Cloudflare returns `result: null` (not `[]`) for a zone whose cert store
323 // is empty — treat it as no matches.
324 return (list.result ?? []).find(
325 (c) =>
326 isLive(c.status) &&
327 normalizePem(c.certificate ?? "") === normalizePem(certificate),
328 );
329 });
330
331const normalizePem = (pem: string): string => pem.trim();
332
333const unwrap = (value: Redacted.Redacted<string> | string): string =>
334 Redacted.isRedacted(value) ? Redacted.value(value) : value;
335
336type CertificateShape = {
337 id?: string | null;
338 status?: string | null;
339 expiresOn?: string | null;
340 issuer?: string | null;
341 signature?: string | null;
342 uploadedOn?: string | null;
343};
344
345const toAttributes = (
346 cert: CertificateShape,
347 zoneId: string,
348): CertificateAttributes => ({
349 certificateId: cert.id!,
350 zoneId,
351 status: cert.status ?? undefined,
352 expiresOn: cert.expiresOn ?? undefined,
353 issuer: cert.issuer ?? undefined,
354 signature: cert.signature ?? undefined,
355 uploadedOn: cert.uploadedOn ?? undefined,
356});