1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
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 { 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
14export type CertificateProps = {
15 /**
16 * Display name for the certificate. Used as a stable identifier so the
17 * provider can locate the certificate during adoption / state recovery.
18 * If omitted, a unique name is generated from the stack/stage/logical id.
19 *
20 * @default ${app}-${stage}-${id}
21 */
22 name?: string;
23 /**
24 * The CA certificate content (PEM). Client certificates presented to
25 * Access-protected applications must chain to this CA. The certificate
26 * body is immutable — changing it replaces the resource.
27 */
28 certificate: string;
29 /**
30 * The hostnames of the Access applications that will use this
31 * certificate for mTLS authentication.
32 *
33 * @default []
34 */
35 associatedHostnames?: string[];
36};
37
38export type Certificate = Resource<
39 "Cloudflare.Access.Certificate",
40 CertificateProps,
41 {
42 /** UUID of the certificate assigned by Cloudflare. */
43 certificateId: string;
44 /** Cloudflare account that owns the certificate. */
45 accountId: string;
46 /** Display name reported by Cloudflare. */
47 name: string;
48 /**
49 * The PEM content that was uploaded. Cloudflare never returns the
50 * certificate body, so the provider persists it to detect replacement.
51 */
52 certificate: string;
53 /** The MD5 fingerprint of the certificate, computed by Cloudflare. */
54 fingerprint: string | undefined;
55 /** Hostnames currently associated with the certificate. */
56 associatedHostnames: string[];
57 /** Expiration timestamp of the CA certificate. */
58 expiresOn: string | undefined;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * A Cloudflare Zero Trust Access mTLS certificate. Uploads a CA certificate
66 * that Access uses to validate client certificates presented to protected
67 * applications on the associated hostnames.
68 *
69 * The certificate body is immutable — changing the PEM replaces the
70 * resource. The name and associated hostnames converge in place.
71 * ### Creating a Certificate
72 * **Example:** Upload a CA certificate
73 * ```typescript
74 * const ca = yield* Cloudflare.Access.Certificate("ClientCa", {
75 * certificate: CA_PEM, // -----BEGIN CERTIFICATE----- ...
76 * });
77 * ```
78 *
79 * **Example:** Certificate with associated hostnames
80 * ```typescript
81 * const ca = yield* Cloudflare.Access.Certificate("ClientCa", {
82 * name: "corp-client-ca",
83 * certificate: CA_PEM,
84 * associatedHostnames: ["app.example.com"],
85 * });
86 * ```
87 *
88 * ### Updating Hostnames
89 * **Example:** Associate more hostnames in place
90 * ```typescript
91 * const ca = yield* Cloudflare.Access.Certificate("ClientCa", {
92 * certificate: CA_PEM,
93 * associatedHostnames: ["app.example.com", "admin.example.com"],
94 * });
95 * ```
96 *
97 * @resource
98 * @product Access
99 * @category Cloudflare One (Zero Trust)
100 */
101export const Certificate = Resource<Certificate>(
102 "Cloudflare.Access.Certificate",
103);
104
105export const isCertificate = (value: unknown): value is Certificate =>
106 Predicate.hasProperty(value, "Type") &&
107 value.Type === "Cloudflare.Access.Certificate";
108
109export const CertificateProvider = () =>
110 Provider.succeed(Certificate, {
111 stables: ["certificateId", "accountId", "fingerprint"],
112 // Account-scoped collection (pattern b). Cloudflare never returns the
113 // certificate PEM body, so the persisted `certificate` field is empty for
114 // enumerated items — every other attribute matches the `read` shape.
115 list: Effect.fn(function* () {
116 const { accountId } = yield* yield* CloudflareEnvironment;
117 return yield* zeroTrust.listAccessCertificatesForAccount
118 .pages({ accountId })
119 .pipe(
120 Stream.runCollect,
121 Effect.map((chunk) =>
122 Array.from(chunk).flatMap((page) =>
123 (page.result ?? [])
124 .filter(
125 (c): c is ObservedCertificate & { id: string } =>
126 c.id != null,
127 )
128 .map((c) => toAttrs(c, accountId, "")),
129 ),
130 ),
131 );
132 }),
133 diff: Effect.fn(function* ({ news, output }) {
134 const { accountId } = yield* yield* CloudflareEnvironment;
135 if (!isResolved(news)) return undefined;
136 if ((output?.accountId ?? accountId) !== accountId) {
137 return { action: "replace" } as const;
138 }
139 // The certificate body is immutable on the Cloudflare API.
140 if (output && news.certificate !== output.certificate) {
141 return { action: "replace" } as const;
142 }
143 // name/associatedHostnames converge via PUT.
144 }),
145 read: Effect.fn(function* ({ id, output, olds }) {
146 const { accountId } = yield* yield* CloudflareEnvironment;
147 const acct = output?.accountId ?? accountId;
148 if (output?.certificateId) {
149 const direct = yield* zeroTrust
150 .getAccessCertificateForAccount({
151 accountId: acct,
152 certificateId: output.certificateId,
153 })
154 .pipe(
155 Effect.catchTag("AccessCertificateNotFound", () =>
156 Effect.succeed(undefined),
157 ),
158 );
159 if (direct && direct.id) {
160 return toAttrs(direct, acct, output.certificate);
161 }
162 }
163 // Cold lookup — the certificate body never comes back from the API,
164 // so recovery is only possible when we still know the uploaded PEM.
165 const pem = olds?.certificate ?? output?.certificate;
166 if (pem === undefined) return undefined;
167 const name = yield* createCertificateName(id, olds?.name ?? output?.name);
168 const existing = yield* findCertificateByName(acct, name);
169 if (!existing || !existing.id) return undefined;
170 return toAttrs(existing, acct, pem);
171 }),
172 reconcile: Effect.fn(function* ({ id, news, output }) {
173 const { accountId } = yield* yield* CloudflareEnvironment;
174 const acct = output?.accountId ?? accountId;
175 const name = yield* createCertificateName(id, news.name);
176 const desiredHostnames = news.associatedHostnames ?? [];
177
178 // Observe — prefer the cached id, fall back to a name lookup so we
179 // recover from out-of-band deletes and state-persistence failures.
180 let observed: ObservedCertificate | undefined;
181 if (output?.certificateId) {
182 observed = yield* zeroTrust
183 .getAccessCertificateForAccount({
184 accountId: acct,
185 certificateId: output.certificateId,
186 })
187 .pipe(
188 Effect.catchTag("AccessCertificateNotFound", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192 }
193 if (!observed || !observed.id) {
194 observed = yield* findCertificateByName(acct, name);
195 }
196
197 // Ensure — upload the certificate when missing. Tolerate a same-named
198 // create race by re-observing.
199 if (!observed || !observed.id) {
200 const created = yield* zeroTrust
201 .createAccessCertificateForAccount({
202 accountId: acct,
203 name,
204 certificate: news.certificate,
205 associatedHostnames: desiredHostnames,
206 })
207 .pipe(
208 Effect.catch((err) =>
209 Effect.gen(function* () {
210 const existing = yield* findCertificateByName(acct, name);
211 if (existing && existing.id) return existing;
212 return yield* Effect.fail(err);
213 }),
214 ),
215 );
216 if (!created.id) {
217 return yield* Effect.fail(
218 new Error("Certificate: created certificate missing id"),
219 );
220 }
221 return toAttrs(created, acct, news.certificate);
222 }
223
224 // Sync — converge name and associated hostnames via PUT only when the
225 // observed state differs from the desired state.
226 const observedHostnames = [...(observed.associatedHostnames ?? [])];
227 if (
228 observed.name !== name ||
229 !sameMembers(observedHostnames, desiredHostnames)
230 ) {
231 const updated = yield* zeroTrust.updateAccessCertificateForAccount({
232 accountId: acct,
233 certificateId: observed.id,
234 name,
235 associatedHostnames: desiredHostnames,
236 });
237 observed = {
238 id: updated.id ?? observed.id,
239 name: updated.name ?? name,
240 fingerprint: updated.fingerprint ?? observed.fingerprint,
241 associatedHostnames: updated.associatedHostnames ?? desiredHostnames,
242 expiresOn: updated.expiresOn ?? observed.expiresOn,
243 };
244 }
245
246 return toAttrs(observed, acct, news.certificate);
247 }),
248 delete: Effect.fn(function* ({ output }) {
249 // The API refuses to delete a certificate that still has associated
250 // hostnames — disassociate first, tolerating an already-gone cert.
251 yield* zeroTrust
252 .updateAccessCertificateForAccount({
253 accountId: output.accountId,
254 certificateId: output.certificateId,
255 name: output.name,
256 associatedHostnames: [],
257 })
258 .pipe(Effect.catchTag("AccessCertificateNotFound", () => Effect.void));
259 yield* zeroTrust
260 .deleteAccessCertificateForAccount({
261 accountId: output.accountId,
262 certificateId: output.certificateId,
263 })
264 .pipe(Effect.catchTag("AccessCertificateNotFound", () => Effect.void));
265 }),
266 });
267
268const createCertificateName = (id: string, name: string | undefined) =>
269 Effect.gen(function* () {
270 if (name) return name;
271 return yield* createPhysicalName({ id });
272 });
273
274const findCertificateByName = (acct: string, name: string) =>
275 zeroTrust.listAccessCertificatesForAccount.items({ accountId: acct }).pipe(
276 Stream.filter((c): c is ObservedCertificate => c.name === name),
277 Stream.runHead,
278 Effect.map(Option.getOrUndefined),
279 Effect.catch(() => Effect.succeed(undefined)),
280 );
281
282const sameMembers = (a: readonly string[], b: readonly string[]) =>
283 a.length === b.length &&
284 [...a].sort().join("\n") === [...b].sort().join("\n");
285
286const toAttrs = (
287 observed: ObservedCertificate,
288 accountId: string,
289 certificate: string,
290) => ({
291 certificateId: observed.id!,
292 accountId,
293 name: observed.name ?? "",
294 certificate,
295 fingerprint: observed.fingerprint ?? undefined,
296 associatedHostnames: [...(observed.associatedHostnames ?? [])],
297 expiresOn: observed.expiresOn ?? undefined,
298});
299
300type ObservedCertificate = {
301 id?: string | null;
302 name?: string | null;
303 fingerprint?: string | null;
304 associatedHostnames?: string[] | null;
305 expiresOn?: string | null;
306};