1import * as clientCertificates from "@distilled.cloud/cloudflare/client-certificates";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
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 { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.ClientCertificate.ClientCertificate" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Lifecycle status of a client certificate. `pending_reactivation` and
19 * `pending_revocation` are in-progress asynchronous transitions.
20 */
21export type Status =
22 | "active"
23 | "pending_reactivation"
24 | "pending_revocation"
25 | "revoked"
26 // Keep the union open so new Cloudflare statuses aren't blocked by stale
27 // types.
28 | (string & {});
29
30export interface Props {
31 /**
32 * Zone the client certificate is issued under. Client certificates are a
33 * zone-level API Shield feature.
34 *
35 * Immutable — moving a certificate between zones triggers a replacement.
36 */
37 zoneId: string;
38 /**
39 * The Certificate Signing Request (CSR) in PEM format. Must be
40 * newline-encoded. Cloudflare's Managed CA signs this CSR and returns the
41 * client certificate.
42 *
43 * Immutable — the API has no way to re-sign a certificate, so changing the
44 * CSR triggers a replacement. Plain `string` (not `string`) so it is
45 * statically comparable inside `diff`.
46 */
47 csr: string;
48 /**
49 * The number of days the client certificate will be valid after the
50 * `issuedOn` date.
51 *
52 * Immutable — changing the validity triggers a replacement.
53 */
54 validityDays: number;
55}
56
57export interface Attributes {
58 /** Cloudflare-assigned identifier of the client certificate. */
59 clientCertificateId: string;
60 /** Zone the certificate was issued under. */
61 zoneId: string;
62 /** The signed client certificate in PEM format. */
63 certificate: string;
64 /** The CSR the certificate was issued from, as echoed by Cloudflare. */
65 csr: string;
66 /** Common Name parsed from the CSR. */
67 commonName: string | undefined;
68 /** Country parsed from the CSR. */
69 country: string | undefined;
70 /** State parsed from the CSR. */
71 state: string | undefined;
72 /** Location parsed from the CSR. */
73 location: string | undefined;
74 /** Organization parsed from the CSR. */
75 organization: string | undefined;
76 /** Organizational Unit parsed from the CSR. */
77 organizationalUnit: string | undefined;
78 /** ISO8601 date the certificate expires. */
79 expiresOn: string | undefined;
80 /** ISO8601 date the certificate was issued by the Managed CA. */
81 issuedOn: string | undefined;
82 /** SHA-256 fingerprint of the certificate. */
83 fingerprintSha256: string | undefined;
84 /** The serial number on the issued certificate. */
85 serialNumber: string | undefined;
86 /** The type of hash used for the certificate signature. */
87 signature: string | undefined;
88 /** Subject Key Identifier. */
89 ski: string | undefined;
90 /** Current lifecycle status of the certificate. */
91 status: Status;
92 /** The number of days the certificate is valid after `issuedOn`. */
93 validityDays: number;
94 /** Identifier of the Certificate Authority that issued the certificate. */
95 certificateAuthorityId: string | undefined;
96 /** Name of the Certificate Authority that issued the certificate. */
97 certificateAuthorityName: string | undefined;
98}
99
100export type ClientCertificate = Resource<
101 TypeId,
102 Props,
103 Attributes,
104 never,
105 Providers
106>;
107
108/**
109 * A zone-level API Shield mTLS client certificate signed by the Cloudflare
110 * Managed CA.
111 *
112 * You submit a Certificate Signing Request (CSR) plus a validity period;
113 * Cloudflare signs it and returns the client certificate PEM, which clients
114 * then present when connecting to API Shield mTLS-protected hostnames.
115 *
116 * Client certificates are immutable: there is no API to change the CSR or
117 * validity, so any prop change triggers a replacement. Deleting the resource
118 * revokes the certificate — revoked certificates remain listed on the zone in
119 * `revoked` status but are treated as deleted by this resource.
120 *
121 * Safety: client certificates carry no ownership markers. When there is no
122 * prior state, `read` scans the zone for a non-revoked certificate issued
123 * from the same CSR and reports it as `Unowned`, so the engine refuses to
124 * take it over unless `--adopt` (or `adopt(true)`) is set.
125 * ### Issuing a client certificate
126 * **Example:** Sign a CSR with the Cloudflare Managed CA
127 * ```typescript
128 * const cert = yield* Cloudflare.ClientCertificate.ClientCertificate("ApiClient", {
129 * zoneId: zone.zoneId,
130 * csr: clientCsrPem,
131 * validityDays: 365,
132 * });
133 * // cert.certificate is the signed client certificate PEM
134 * ```
135 *
136 * **Example:** Read the CSR from disk
137 * ```typescript
138 * const fs = yield* FileSystem.FileSystem;
139 * const csr = yield* fs.readFileString("certs/client.csr");
140 *
141 * const cert = yield* Cloudflare.ClientCertificate.ClientCertificate("ApiClient", {
142 * zoneId: zone.zoneId,
143 * csr,
144 * validityDays: 90,
145 * });
146 * ```
147 *
148 * ### Rotation
149 * **Example:** Rotate by changing the CSR
150 * ```typescript
151 * // csr and validityDays are immutable — changing either replaces the
152 * // certificate: a new one is signed and the old one is revoked.
153 * const cert = yield* Cloudflare.ClientCertificate.ClientCertificate("ApiClient", {
154 * zoneId: zone.zoneId,
155 * csr: rotatedCsrPem,
156 * validityDays: 365,
157 * });
158 * ```
159 *
160 * @see https://developers.cloudflare.com/ssl/client-certificates/
161 *
162 * @resource
163 * @product Client Certificates
164 * @category SSL/TLS & Certificates
165 */
166export const ClientCertificate = Resource<ClientCertificate>(TypeId, {
167 aliases: ["Cloudflare.ClientCertificate"],
168});
169
170/**
171 * Returns true if the given value is a ClientCertificate resource.
172 */
173export const isClientCertificate = (
174 value: unknown,
175): value is ClientCertificate =>
176 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
177
178export const ClientCertificateProvider = () =>
179 Provider.succeed(ClientCertificate, {
180 // Everything Cloudflare returns at issuance is fixed for the lifetime of
181 // the certificate — only `status` changes (revocation/reactivation).
182 stables: [
183 "clientCertificateId",
184 "zoneId",
185 "certificate",
186 "csr",
187 "commonName",
188 "country",
189 "state",
190 "location",
191 "organization",
192 "organizationalUnit",
193 "expiresOn",
194 "issuedOn",
195 "fingerprintSha256",
196 "serialNumber",
197 "signature",
198 "ski",
199 "validityDays",
200 "certificateAuthorityId",
201 "certificateAuthorityName",
202 ],
203
204 // Client certificates are a zone-scoped API Shield feature. Fan out over
205 // every zone in the account, exhaustively paginate each zone's
206 // certificates, and hydrate each into the same Attributes shape `read`
207 // produces. Revoked certificates stay listed forever but revocation is the
208 // delete semantic, so they are filtered out. Zones where API Shield is not
209 // entitled reject with the typed `Forbidden` error — skip them.
210 list: Effect.fn(function* () {
211 const { accountId } = yield* yield* CloudflareEnvironment;
212 const zones = yield* listAllZones(accountId);
213 const rows = yield* Effect.forEach(
214 zones,
215 (zone) =>
216 clientCertificates.listClientCertificates
217 .pages({ zoneId: zone.id, status: "all" })
218 .pipe(
219 Stream.runCollect,
220 Effect.map((chunk) =>
221 Array.from(chunk).flatMap((page) =>
222 (page.result ?? [])
223 .filter((cert) => !isGone(cert.status))
224 .map((cert): Attributes => toAttributes(cert, zone.id)),
225 ),
226 ),
227 Effect.catchTag("Forbidden", () => Effect.succeed([])),
228 ),
229 { concurrency: 10 },
230 );
231 return rows.flat();
232 }),
233
234 diff: Effect.fn(function* ({ olds = {}, news }) {
235 const o = olds as Props;
236 const n = news as Props;
237 // No prior props to compare against — let the engine decide.
238 if (o.csr === undefined) return undefined;
239 // The API has no update for csr/validityDays — every change replaces.
240 if (normalizePem(o.csr) !== normalizePem(n.csr)) {
241 return { action: "replace" } as const;
242 }
243 if (o.validityDays !== n.validityDays) {
244 return { action: "replace" } as const;
245 }
246 // zoneId is Input<string>; compare only once both are concrete.
247 if (
248 typeof o.zoneId === "string" &&
249 typeof n.zoneId === "string" &&
250 o.zoneId !== n.zoneId
251 ) {
252 return { action: "replace" } as const;
253 }
254 return undefined;
255 }),
256
257 read: Effect.fn(function* ({ output, olds }) {
258 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
259 if (zoneId === undefined) return undefined;
260
261 // Owned path: refresh by our persisted certificate id. A revoked (or
262 // asynchronously revoking) certificate stays listed on the zone forever
263 // — revocation is the delete semantic, so report it as gone.
264 if (output?.clientCertificateId) {
265 const observed = yield* getCertificate(
266 zoneId,
267 output.clientCertificateId,
268 );
269 if (observed && !isGone(observed.status)) {
270 return toAttributes(observed, zoneId);
271 }
272 return undefined;
273 }
274
275 // Adoption path: a non-revoked certificate issued from the same CSR
276 // (with the same validity) may already exist on the zone. Client
277 // certificates carry no ownership markers, so we cannot prove we
278 // created it — brand it `Unowned` so the engine refuses takeover
279 // unless `adopt` is set.
280 if (olds?.csr !== undefined && olds.validityDays !== undefined) {
281 const observed = yield* findByCsr(zoneId, olds.csr, olds.validityDays);
282 if (observed) return Unowned(toAttributes(observed, zoneId));
283 }
284 return undefined;
285 }),
286
287 reconcile: Effect.fn(function* ({ news, output }) {
288 // Inputs have been resolved to concrete strings by Plan.
289 const zoneId = news.zoneId as string;
290
291 // 1. Observe — the certificate id cached on `output` is a hint, not a
292 // guarantee. A missing or revoked certificate (revocation is the
293 // delete semantic; the certificate cannot serve mTLS) falls
294 // through to the CSR scan and then to create.
295 let observed = output?.clientCertificateId
296 ? yield* getCertificate(zoneId, output.clientCertificateId)
297 : undefined;
298 if (observed && isGone(observed.status)) observed = undefined;
299
300 // 2. Fall back to scanning the zone for a non-revoked certificate
301 // issued from the same CSR with the same validity. Matching on
302 // BOTH props matters: a replacement keeps one of them while
303 // changing the other, so the old certificate never matches the new
304 // desired state and cannot be mistaken for it. Ownership has
305 // already been verified upstream — `read` reports existing
306 // certificates as `Unowned` and the engine gates takeover behind
307 // the adopt policy before reconcile ever runs.
308 if (!observed) {
309 observed = yield* findByCsr(zoneId, news.csr, news.validityDays);
310 }
311
312 // 3. Ensure — sign the CSR when missing. Cloudflare happily signs the
313 // same CSR multiple times (each issuance is a distinct
314 // certificate), so there is no AlreadyExists race to tolerate.
315 if (!observed) {
316 observed = yield* clientCertificates.createClientCertificate({
317 zoneId,
318 csr: news.csr,
319 validityDays: news.validityDays,
320 });
321 }
322
323 // 4. There is no sync step: csr/validityDays are immutable and every
324 // other attribute is derived at issuance.
325 return toAttributes(observed, zoneId);
326 }),
327
328 delete: Effect.fn(function* ({ output }) {
329 // DELETE revokes the certificate. Revoked certificates remain listed
330 // on the zone — observe first and treat already-revoked (or already
331 // revoking) as done so delete is idempotent.
332 const observed = yield* getCertificate(
333 output.zoneId,
334 output.clientCertificateId,
335 );
336 if (!observed || isGone(observed.status)) {
337 return;
338 }
339 yield* clientCertificates
340 .deleteClientCertificate({
341 zoneId: output.zoneId,
342 clientCertificateId: output.clientCertificateId,
343 })
344 .pipe(
345 // Races with an out-of-band revoke/delete are convergence, not
346 // failure.
347 Effect.catchTag("ClientCertificateNotFound", () => Effect.void),
348 Effect.catchTag("ClientCertificateAlreadyRevoked", () => Effect.void),
349 );
350 }),
351 });
352
353// ---------------------------------------------------------------------------
354// API helpers
355// ---------------------------------------------------------------------------
356
357type ObservedCertificate = clientCertificates.GetClientCertificateResponse;
358
359/**
360 * Read a certificate by id, mapping "gone" (`ClientCertificateNotFound`,
361 * Cloudflare error code 1415 `Invalid Certificate ID in request`) to
362 * `undefined`.
363 */
364const getCertificate = (zoneId: string, clientCertificateId: string) =>
365 clientCertificates.getClientCertificate({ zoneId, clientCertificateId }).pipe(
366 Effect.map((cert): ObservedCertificate | undefined => cert),
367 Effect.catchTag("ClientCertificateNotFound", () =>
368 Effect.succeed(undefined),
369 ),
370 );
371
372/**
373 * Find a non-revoked certificate issued from the given CSR with the given
374 * validity. Cloudflare echoes the CSR back on every certificate, so an exact
375 * (whitespace-normalized) CSR + validity match is the only identity available
376 * for cold reads — certificates have no names or tags. Both props are matched
377 * so a replacement (which changes at least one of them) can never mistake the
378 * outgoing certificate for the desired one.
379 */
380const findByCsr = (zoneId: string, csr: string, validityDays: number) =>
381 clientCertificates.listClientCertificates
382 .items({ zoneId, status: "all" })
383 .pipe(
384 Stream.filter(
385 (cert) =>
386 !isGone(cert.status) &&
387 cert.validityDays === validityDays &&
388 typeof cert.csr === "string" &&
389 normalizePem(cert.csr) === normalizePem(csr),
390 ),
391 Stream.runHead,
392 Effect.map(Option.getOrUndefined),
393 Effect.map((cert): ObservedCertificate | undefined => cert),
394 );
395
396/**
397 * A certificate is "gone" once it is `revoked` or asynchronously transitioning
398 * to it (`pending_revocation`). Revocation is the delete semantic, and a
399 * revoking certificate can no longer serve mTLS — so it must never be adopted,
400 * reused, listed, or treated as live. Without this, a prior run's async revoke
401 * leaves a `pending_revocation` certificate that a fresh deploy would otherwise
402 * mistake for an existing live certificate matching the CSR.
403 */
404const isGone = (status: string | null | undefined): boolean =>
405 status === "revoked" || status === "pending_revocation";
406
407/** Normalize PEM content for comparison (CRLF + trailing-newline noise). */
408const normalizePem = (pem: string | undefined): string | undefined =>
409 pem?.replace(/\r\n/g, "\n").trim();
410
411const toAttributes = (
412 cert: ObservedCertificate,
413 zoneId: string,
414): Attributes => ({
415 clientCertificateId: cert.id!,
416 zoneId,
417 certificate: cert.certificate ?? "",
418 csr: cert.csr ?? "",
419 commonName: cert.commonName ?? undefined,
420 country: cert.country ?? undefined,
421 state: cert.state ?? undefined,
422 location: cert.location ?? undefined,
423 organization: cert.organization ?? undefined,
424 organizationalUnit: cert.organizationalUnit ?? undefined,
425 expiresOn: cert.expiresOn ?? undefined,
426 issuedOn: cert.issuedOn ?? undefined,
427 fingerprintSha256: cert.fingerprintSha256 ?? undefined,
428 serialNumber: cert.serialNumber ?? undefined,
429 signature: cert.signature ?? undefined,
430 ski: cert.ski ?? undefined,
431 status: (cert.status ?? "active") as Status,
432 validityDays: cert.validityDays ?? 0,
433 certificateAuthorityId: cert.certificateAuthority?.id ?? undefined,
434 certificateAuthorityName: cert.certificateAuthority?.name ?? undefined,
435});