1import * as mtls from "@distilled.cloud/cloudflare/mtls-certificates";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.MtlsCertificate.MtlsCertificate" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * How the certificate was created and who manages it. Certificates uploaded
19 * through this resource are always `"custom"`.
20 */
21export type Type =
22 | "custom"
23 | "gateway_managed"
24 | "access_managed"
25 // Keep the union open so new Cloudflare types aren't blocked by stale types.
26 | (string & {});
27
28export type Props = {
29 /**
30 * Optional human-readable name for the certificate. If omitted, a unique
31 * name will be generated. There is no update API, so changing the name
32 * triggers a replacement.
33 * @default ${app}-${stage}-${id}
34 */
35 name?: string;
36 /**
37 * Indicates whether the certificate is a CA certificate (`true`) used to
38 * validate client certificates, or a leaf certificate (`false`) presented
39 * by Cloudflare to your origin. Cannot be changed after upload — updating
40 * this property triggers a replacement.
41 */
42 ca: boolean;
43 /**
44 * The certificate in PEM format. A root CA certificate when `ca: true`,
45 * or a leaf certificate (chain) when `ca: false`. Cannot be changed after
46 * upload — updating this property triggers a replacement.
47 */
48 certificates: string;
49 /**
50 * The private key for the certificate in PEM format. Required when
51 * uploading a leaf certificate (`ca: false`) that Cloudflare must present
52 * to your origin. Cannot be changed after upload — updating this property
53 * triggers a replacement.
54 */
55 privateKey?: Redacted.Redacted<string>;
56};
57
58export type Attributes = {
59 /**
60 * Unique identifier of the uploaded certificate.
61 */
62 mtlsCertificateId: string;
63 /**
64 * The Cloudflare account the certificate was uploaded to.
65 */
66 accountId: string;
67 /**
68 * Human-readable name of the certificate.
69 */
70 name: string | undefined;
71 /**
72 * Whether the certificate is a CA (`true`) or leaf (`false`) certificate.
73 */
74 ca: boolean;
75 /**
76 * The certificate authority that issued the certificate.
77 */
78 issuer: string | undefined;
79 /**
80 * The certificate serial number.
81 */
82 serialNumber: string | undefined;
83 /**
84 * The type of hash used for the certificate signature.
85 */
86 signature: string | undefined;
87 /**
88 * When the certificate expires.
89 */
90 expiresOn: string | undefined;
91 /**
92 * When the certificate was uploaded to Cloudflare.
93 */
94 uploadedOn: string | undefined;
95 /**
96 * How the certificate was created and who manages it.
97 */
98 type: Type | undefined;
99};
100
101export type MtlsCertificate = Resource<
102 TypeId,
103 Props,
104 Attributes,
105 never,
106 Providers
107>;
108
109/**
110 * An account-level Cloudflare mTLS certificate.
111 *
112 * Uploads a certificate to the account-level mTLS certificate store. Upload a
113 * CA certificate (`ca: true`) to validate client certificates (referenced by
114 * certificate-authority hostname associations and Hyperdrive
115 * `caCertificateId`), or a leaf certificate plus private key (`ca: false`)
116 * that Cloudflare presents to your origin (referenced by Worker
117 * `mtls_certificate` bindings and Hyperdrive `mtlsCertificateId`).
118 *
119 * Certificates are immutable: there is no update API, so changing any
120 * property triggers a replacement.
121 * ### Uploading Certificates
122 * **Example:** CA certificate
123 * ```typescript
124 * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("client-ca", {
125 * ca: true,
126 * certificates: caPem,
127 * });
128 * ```
129 *
130 * **Example:** Leaf certificate with private key
131 * ```typescript
132 * const cert = yield* Cloudflare.MtlsCertificate.MtlsCertificate("origin-client-cert", {
133 * ca: false,
134 * certificates: leafPem,
135 * privateKey: yield* Config.Redacted("ORIGIN_CLIENT_KEY"),
136 * });
137 * ```
138 *
139 * **Example:** Named certificate
140 * ```typescript
141 * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("client-ca", {
142 * name: "my-client-ca",
143 * ca: true,
144 * certificates: caPem,
145 * });
146 * ```
147 *
148 * ### Referencing from Hyperdrive
149 * **Example:** Verify the origin with an uploaded CA
150 * ```typescript
151 * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("db-ca", {
152 * ca: true,
153 * certificates: caPem,
154 * });
155 *
156 * const hd = yield* Cloudflare.Hyperdrive.Connection("my-db", {
157 * origin: { ... },
158 * mtls: {
159 * caCertificateId: ca.mtlsCertificateId,
160 * sslmode: "verify-full",
161 * },
162 * });
163 * ```
164 *
165 * @see https://developers.cloudflare.com/ssl/client-certificates/
166 *
167 * @resource
168 * @product mTLS Certificates
169 * @category SSL/TLS & Certificates
170 */
171export const MtlsCertificate = Resource<MtlsCertificate>(TypeId, {
172 aliases: ["Cloudflare.MtlsCertificate"],
173});
174
175/**
176 * Returns true if the given value is a MtlsCertificate resource.
177 */
178export const isMtlsCertificate = (value: unknown): value is MtlsCertificate =>
179 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
180
181export const MtlsCertificateProvider = () =>
182 Provider.succeed(MtlsCertificate, {
183 stables: ["mtlsCertificateId", "accountId"],
184 diff: Effect.fn(function* ({ id, olds, news, output }) {
185 const { accountId } = yield* yield* CloudflareEnvironment;
186 if (!isResolved(news)) return undefined;
187 if ((output?.accountId ?? accountId) !== accountId) {
188 return { action: "replace" } as const;
189 }
190 const oldName =
191 output?.name ?? (yield* createCertificateName(id, olds.name));
192 // Auto-generated names are engine-owned: the deployed name stays
193 // authoritative even if the generator would name this id differently
194 // today. Only an explicit user-provided name can force a replace.
195 const name = news.name ?? oldName;
196 if (
197 oldName !== name ||
198 (news.ca ?? undefined) !== (olds.ca ?? undefined) ||
199 (news.certificates ?? undefined) !== (olds.certificates ?? undefined) ||
200 unwrap(news.privateKey) !== unwrap(olds.privateKey)
201 ) {
202 // There is no update API for mTLS certificates — every change is a
203 // replacement.
204 return { action: "replace" } as const;
205 }
206 return undefined;
207 }),
208 read: Effect.fn(function* ({ id, output, olds }) {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210 const acct = output?.accountId ?? accountId;
211 if (output?.mtlsCertificateId) {
212 return yield* mtls
213 .getMtlsCertificate({
214 accountId: acct,
215 mtlsCertificateId: output.mtlsCertificateId,
216 })
217 .pipe(
218 Effect.map((cert) => toAttributes(cert, acct)),
219 Effect.catchTag("CertificateNotFound", () =>
220 Effect.succeed(undefined),
221 ),
222 );
223 }
224 // Cold read — recover by listing and matching on the deterministic
225 // physical name (the only brand available; certificates have no tags).
226 const name = yield* createCertificateName(id, olds?.name);
227 const match = yield* findByName(acct, name);
228 return match ? toAttributes(match, acct) : undefined;
229 }),
230 list: Effect.fn(function* () {
231 const { accountId } = yield* yield* CloudflareEnvironment;
232 // Account-scoped collection — exhaustively paginate the mTLS
233 // certificate store and hydrate each item into the read Attributes
234 // shape (the private key is write-only and never returned).
235 return yield* mtls.listMtlsCertificates.pages({ accountId }).pipe(
236 Stream.runCollect,
237 Effect.map((chunk) =>
238 Array.from(chunk).flatMap((page) =>
239 (page.result ?? [])
240 // Cloudflare-managed certificates (e.g. the gateway/access
241 // managed CAs) reject deletion with `Unauthorized`; only
242 // enumerate user-uploaded `custom` certificates for teardown.
243 .filter(
244 (cert) =>
245 cert.type !== "gateway_managed" &&
246 cert.type !== "access_managed",
247 )
248 .map((cert) => toAttributes(cert, accountId)),
249 ),
250 ),
251 );
252 }),
253 reconcile: Effect.fn(function* ({ id, news, output }) {
254 const { accountId } = yield* yield* CloudflareEnvironment;
255 const name =
256 output?.name ?? (yield* createCertificateName(id, news.name));
257
258 // Observe — the certificate id is the stable identifier; fall through
259 // a CertificateNotFound to the list+name match so we recover from
260 // out-of-band deletes and partial state persistence.
261 let observed = output?.mtlsCertificateId
262 ? yield* mtls
263 .getMtlsCertificate({
264 accountId,
265 mtlsCertificateId: output.mtlsCertificateId,
266 })
267 .pipe(
268 Effect.catchTag("CertificateNotFound", () =>
269 Effect.succeed(undefined),
270 ),
271 )
272 : yield* findByName(accountId, name);
273
274 // Ensure — upload if missing. Cloudflare rejects uploading a
275 // certificate with identical content twice (code 1471); tolerate the
276 // race (or an orphaned prior upload) by re-listing and adopting the
277 // certificate with identical PEM content.
278 if (!observed) {
279 observed = yield* mtls
280 .createMtlsCertificate({
281 accountId,
282 ca: news.ca,
283 certificates: news.certificates,
284 name,
285 privateKey: unwrap(news.privateKey),
286 })
287 .pipe(
288 Effect.catchTag("CertificateAlreadyExists", (originalError) =>
289 Effect.gen(function* () {
290 const match = yield* findByContent(
291 accountId,
292 news.certificates,
293 );
294 if (!match) return yield* Effect.fail(originalError);
295 return match;
296 }),
297 ),
298 );
299 }
300
301 return toAttributes(observed, accountId, news);
302 }),
303 delete: Effect.fn(function* ({ output }) {
304 yield* mtls
305 .deleteMtlsCertificate({
306 accountId: output.accountId,
307 mtlsCertificateId: output.mtlsCertificateId,
308 })
309 .pipe(
310 Effect.catchTag(
311 ["CertificateNotFound", "CertificateAlreadyDeleted"],
312 () => Effect.void,
313 ),
314 );
315 }),
316 });
317
318const createCertificateName = (id: string, name: string | undefined) =>
319 Effect.gen(function* () {
320 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
321 });
322
323const findByName = (accountId: string, name: string) =>
324 Effect.gen(function* () {
325 const list = yield* mtls.listMtlsCertificates({ accountId });
326 return list.result.find((c) => c.name === name);
327 });
328
329const findByContent = (accountId: string, certificates: string) =>
330 Effect.gen(function* () {
331 const list = yield* mtls.listMtlsCertificates({ accountId });
332 return list.result.find(
333 (c) => c.certificates?.trim() === certificates.trim(),
334 );
335 });
336
337const unwrap = (
338 value: Redacted.Redacted<string> | undefined,
339): string | undefined =>
340 value === undefined
341 ? undefined
342 : Redacted.isRedacted(value)
343 ? Redacted.value(value)
344 : value;
345
346type CertificateShape = {
347 id?: string | null;
348 ca?: boolean | null;
349 name?: string | null;
350 issuer?: string | null;
351 serialNumber?: string | null;
352 signature?: string | null;
353 expiresOn?: string | null;
354 uploadedOn?: string | null;
355 type?: Type | null;
356};
357
358const toAttributes = (
359 cert: CertificateShape,
360 accountId: string,
361 news?: Props,
362): Attributes => ({
363 mtlsCertificateId: cert.id!,
364 accountId,
365 name: cert.name ?? undefined,
366 ca: cert.ca ?? news?.ca ?? false,
367 issuer: cert.issuer ?? undefined,
368 serialNumber: cert.serialNumber ?? undefined,
369 signature: cert.signature ?? undefined,
370 expiresOn: cert.expiresOn ?? undefined,
371 uploadedOn: cert.uploadedOn ?? undefined,
372 type: cert.type ?? undefined,
373});