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.HostnameCertificate" 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 HostnameCertificateStatus =
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 HostnameCertificateProps = {
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 per-hostname client certificate in PEM format, presented by
42 * Cloudflare to your origin for hostnames associated with it via
43 * {@link HostnameAssociation}. Cannot be changed after
44 * upload — updating this property triggers a 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 HostnameCertificateAttributes = {
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: HostnameCertificateStatus | undefined;
61 /** When the certificate expires. */
62 expiresOn: string | undefined;
63 /** The certificate authority that issued the certificate. */
64 issuer: string | undefined;
65 /** The serial number on the uploaded certificate. */
66 serialNumber: string | undefined;
67 /** The type of hash used for the certificate signature. */
68 signature: string | undefined;
69 /** When the certificate was uploaded to Cloudflare. */
70 uploadedOn: string | undefined;
71};
72
73export type HostnameCertificate = Resource<
74 TypeId,
75 HostnameCertificateProps,
76 HostnameCertificateAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * A per-hostname Authenticated Origin Pulls (AOP) client certificate
83 * (`/zones/{zone_id}/origin_tls_client_auth/hostnames/certificates`).
84 *
85 * Uploads a client certificate that Cloudflare presents to your origin for
86 * specific hostnames. Hostnames opt in by referencing the certificate from an
87 * {@link HostnameAssociation}, which pins the certificate
88 * and enables hostname-level AOP.
89 *
90 * Certificates are immutable: there is no update API, so changing any
91 * property triggers a replacement. Deployment is asynchronous — the
92 * certificate starts in `pending_deployment` and becomes `active` within a
93 * few minutes; deletion likewise passes through `pending_deletion`.
94 * ### Uploading a hostname certificate
95 * **Example:** Hostname client certificate
96 * ```typescript
97 * const cert = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate("AopHostCert", {
98 * zoneId: zone.zoneId,
99 * certificate: clientCertPem,
100 * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"),
101 * });
102 * ```
103 *
104 * ### Enabling AOP for a hostname
105 * **Example:** Upload the certificate and associate a hostname
106 * ```typescript
107 * const cert = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate("AopHostCert", {
108 * zoneId: zone.zoneId,
109 * certificate: clientCertPem,
110 * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"),
111 * });
112 *
113 * yield* Cloudflare.OriginTlsClientAuth.HostnameAssociation("AopHost", {
114 * zoneId: zone.zoneId,
115 * hostname: "api.example.com",
116 * certId: cert.certificateId,
117 * enabled: true,
118 * });
119 * ```
120 *
121 * @see https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/set-up/per-hostname/
122 *
123 * @resource
124 * @product Origin TLS Client Auth
125 * @category SSL/TLS & Certificates
126 */
127export const HostnameCertificate = Resource<HostnameCertificate>(TypeId);
128
129/**
130 * Returns true if the given value is an HostnameCertificate
131 * resource.
132 */
133export const isHostnameCertificate = (
134 value: unknown,
135): value is HostnameCertificate =>
136 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
137
138export const HostnameCertificateProvider = () =>
139 Provider.succeed(HostnameCertificate, {
140 stables: ["certificateId", "zoneId"],
141
142 list: Effect.fn(function* () {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144 // Per-hostname client certificates live inside a zone and are only
145 // enumerable per-zone — fan out over every zone in the account.
146 const zones = yield* listAllZones(accountId);
147 const rows = yield* Effect.forEach(
148 zones,
149 (zone) =>
150 originTls.listHostnameCertificates.pages({ zoneId: zone.id }).pipe(
151 Stream.runCollect,
152 Effect.map((chunk) =>
153 Array.from(chunk).flatMap((page) =>
154 (page.result ?? [])
155 // Match `read`: tombstoned (deleted / pending_deletion)
156 // certificates no longer satisfy the desired state.
157 .filter((c) => isLive(c.status))
158 .map((c) => toAttributes(c, zone.id)),
159 ),
160 ),
161 // Plan-gated / partial zones reject the route; skip them.
162 Effect.catchTag("Forbidden", () => Effect.succeed([])),
163 ),
164 { concurrency: 10 },
165 );
166 return rows.flat();
167 }),
168
169 diff: Effect.fn(function* ({ olds, news }) {
170 if (!isResolved(news)) return undefined;
171 // zoneId is Input<string>; compare only once both sides are concrete.
172 if (
173 typeof olds.zoneId === "string" &&
174 typeof news.zoneId === "string" &&
175 olds.zoneId !== news.zoneId
176 ) {
177 return { action: "replace" } as const;
178 }
179 // Compare the certificate only when the old value is known — an
180 // Output-valued `certificate` doesn't survive a `creating`-state
181 // round-trip (it deserializes as `undefined`).
182 if (
183 (olds.certificate !== undefined &&
184 normalizePem(olds.certificate) !== normalizePem(news.certificate)) ||
185 unwrap(olds.privateKey) !== unwrap(news.privateKey)
186 ) {
187 // There is no update API for hostname client certificates — every
188 // change is a replacement.
189 return { action: "replace" } as const;
190 }
191 return undefined;
192 }),
193
194 read: Effect.fn(function* ({ output, olds }) {
195 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
196 if (!zoneId) return undefined;
197 if (output?.certificateId) {
198 const observed = yield* observeById(zoneId, output.certificateId);
199 if (observed) return toAttributes(observed, zoneId);
200 }
201 // Cold read — recover by listing and matching on the certificate PEM
202 // content (the only identity available; hostname client certificates
203 // have no name or tags). A content match IS the desired state, so
204 // adoption is safe without an `Unowned` gate.
205 if (typeof olds?.certificate === "string") {
206 const match = yield* findByContent(zoneId, olds.certificate);
207 if (match) return toAttributes(match, zoneId);
208 }
209 return undefined;
210 }),
211
212 reconcile: Effect.fn(function* ({ news, output }) {
213 // Inputs have been resolved to concrete strings by Plan.
214 const zoneId = news.zoneId as string;
215
216 // 1. Observe — the certificate id is the stable identifier; fall
217 // through a HostnameCertificateNotFound (or a pending/completed
218 // deletion) to the list+content match so we recover from
219 // out-of-band deletes and partial state persistence.
220 let observed = output?.certificateId
221 ? yield* observeById(zoneId, output.certificateId)
222 : yield* findByContent(zoneId, news.certificate);
223
224 // 2. Ensure — upload if missing. Cloudflare rejects uploading a PEM
225 // identical to an existing certificate (code 1406); tolerate the
226 // race (or an orphaned prior upload) by re-listing and adopting the
227 // certificate with identical PEM content. A destroy→deploy cycle
228 // also hits 1406 while the old certificate's tombstone is still
229 // `pending_deletion` (observed live: it settles to `deleted` within
230 // ~10 seconds, after which re-uploading the PEM resurrects it under
231 // the same id) — ride that window out with a bounded retry of the
232 // whole create-or-adopt step.
233 if (!observed) {
234 observed = yield* originTls
235 .createHostnameCertificate({
236 zoneId,
237 certificate: news.certificate,
238 privateKey: unwrap(news.privateKey),
239 })
240 .pipe(
241 Effect.catchTag("CertificateAlreadyExists", (originalError) =>
242 Effect.gen(function* () {
243 const match = yield* findByContent(zoneId, news.certificate);
244 if (!match) return yield* Effect.fail(originalError);
245 return match;
246 }),
247 ),
248 Effect.retry({
249 while: (e) => e._tag === "CertificateAlreadyExists",
250 schedule: Schedule.spaced("5 seconds"),
251 times: 10,
252 }),
253 );
254 }
255
256 // 3. Return — deployment is asynchronous (`pending_deployment` →
257 // `active`); we do not block on activation.
258 return toAttributes(observed, zoneId);
259 }),
260
261 delete: Effect.fn(function* ({ output }) {
262 yield* originTls
263 .deleteHostnameCertificate({
264 zoneId: output.zoneId,
265 certificateId: output.certificateId,
266 })
267 .pipe(
268 // The certificate cannot be deleted while a hostname association
269 // still references it (code 1433) or while its initial deployment
270 // is still propagating (code 1434). Both clear within seconds once
271 // the association is voided / the deployment lands — ride them out
272 // with a bounded retry.
273 Effect.retry({
274 while: (e) =>
275 e._tag === "HostnameCertificateInUse" ||
276 e._tag === "CertificatePendingDeployment",
277 schedule: Schedule.spaced("5 seconds"),
278 times: 10,
279 }),
280 // Already gone (404 / code 1551) or already on its way out
281 // (code 1414 pending deletion) — delete is idempotent.
282 Effect.catchTag(
283 ["HostnameCertificateNotFound", "CertificatePendingDeletion"],
284 () => Effect.void,
285 ),
286 );
287 }),
288 });
289
290/** A certificate that is being or has been deleted no longer satisfies the desired state. */
291const isLive = (status: string | null | undefined): boolean =>
292 status !== "deleted" && status !== "pending_deletion";
293
294// Observe a certificate by id, treating "not found" and "deleted /
295// pending_deletion" (Cloudflare keeps tombstones readable by id) as missing.
296const observeById = (zoneId: string, certificateId: string) =>
297 originTls.getHostnameCertificate({ zoneId, certificateId }).pipe(
298 Effect.map((cert) => (isLive(cert.status) ? cert : undefined)),
299 Effect.catchTag("HostnameCertificateNotFound", () =>
300 Effect.succeed(undefined),
301 ),
302 );
303
304// Locate a live certificate by exact PEM content. Tombstoned certificates
305// are excluded so a destroy→deploy cycle re-creates (resurrects) instead of
306// adopting a certificate that is on its way out.
307const findByContent = (zoneId: string, certificate: string) =>
308 Effect.gen(function* () {
309 const list = yield* originTls.listHostnameCertificates({ zoneId });
310 return list.result.find(
311 (c) =>
312 isLive(c.status) &&
313 normalizePem(c.certificate ?? "") === normalizePem(certificate),
314 );
315 });
316
317const normalizePem = (pem: string): string => pem.trim();
318
319const unwrap = (value: Redacted.Redacted<string> | string): string =>
320 Redacted.isRedacted(value) ? Redacted.value(value) : value;
321
322type CertificateShape = {
323 id?: string | null;
324 status?: string | null;
325 expiresOn?: string | null;
326 issuer?: string | null;
327 serialNumber?: string | null;
328 signature?: string | null;
329 uploadedOn?: string | null;
330};
331
332const toAttributes = (
333 cert: CertificateShape,
334 zoneId: string,
335): HostnameCertificateAttributes => ({
336 certificateId: cert.id!,
337 zoneId,
338 status: cert.status ?? undefined,
339 expiresOn: cert.expiresOn ?? undefined,
340 issuer: cert.issuer ?? undefined,
341 serialNumber: cert.serialNumber ?? undefined,
342 signature: cert.signature ?? undefined,
343 uploadedOn: cert.uploadedOn ?? undefined,
344});