1import * as acm from "@distilled.cloud/cloudflare/acm";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.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.Acm.CustomTrustStore" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Lifecycle status of an uploaded custom trust store certificate.
19 * Upload is asynchronous: certificates start `initializing` and progress
20 * to `active`; deletes go through `pending_deletion` before `deleted`.
21 */
22export type CustomTrustStoreStatus =
23 | "initializing"
24 | "pending_deployment"
25 | "active"
26 | "pending_deletion"
27 | "deleted"
28 | "expired"
29 | (string & {});
30
31export interface CustomTrustStoreProps {
32 /**
33 * Zone the trust store certificate belongs to.
34 *
35 * Immutable — moving the certificate to another zone triggers a
36 * replacement.
37 */
38 zoneId: string;
39 /**
40 * The root CA certificate in PEM format. Only root CA certificates are
41 * accepted; intermediate and leaf certificates are rejected by
42 * Cloudflare.
43 *
44 * Immutable — the API has no update operation, so changing the
45 * certificate triggers a replacement.
46 */
47 certificate: string;
48}
49
50export interface CustomTrustStoreAttributes {
51 /** Cloudflare-assigned identifier of the trust store certificate. */
52 id: string;
53 /** Zone the certificate belongs to. */
54 zoneId: string;
55 /** The root CA certificate in PEM format, as echoed by Cloudflare. */
56 certificate: string;
57 /** When the certificate expires. */
58 expiresOn: string;
59 /** The certificate authority that issued the certificate. */
60 issuer: string;
61 /** The type of hash used for the certificate. */
62 signature: string;
63 /** Deployment status of the certificate. */
64 status: CustomTrustStoreStatus;
65 /** When the certificate was uploaded to Cloudflare. */
66 uploadedOn: string;
67 /** When the certificate was last modified. */
68 updatedAt: string;
69}
70
71export type CustomTrustStore = Resource<
72 TypeId,
73 CustomTrustStoreProps,
74 CustomTrustStoreAttributes,
75 never,
76 Providers
77>;
78
79/**
80 * A root CA certificate in a zone's custom origin trust store
81 * (`/zones/{zone_id}/acm/custom_trust_store`). Cloudflare uses the trust
82 * store to validate your origin server's certificate when connecting to
83 * the origin (e.g. with Full (strict) SSL and a private CA at the origin).
84 *
85 * Requires the Advanced Certificate Manager entitlement on the zone —
86 * without it every call fails with the typed
87 * `AdvancedCertificateManagerRequired` (code 1450) error.
88 *
89 * The certificate is immutable: there is no update API, so changing the
90 * PEM (or the zone) replaces the resource. Trust store certificates carry
91 * no ownership markers, so a cold `read` scans the zone for a certificate
92 * with the same PEM body and reports it as `Unowned` — the engine refuses
93 * to take it over unless `--adopt` (or `adopt(true)`) is set.
94 * ### Uploading a root CA
95 * **Example:** Trust a private root CA for origin pulls
96 * ```typescript
97 * const trustStore = yield* Cloudflare.Acm.CustomTrustStore("OriginRootCa", {
98 * zoneId: zone.zoneId,
99 * certificate: rootCaPem, // "-----BEGIN CERTIFICATE-----\n..."
100 * });
101 * ```
102 *
103 * **Example:** Load the PEM from a file
104 * ```typescript
105 * const fs = yield* FileSystem.FileSystem;
106 * const pem = yield* fs.readFileString("./certs/root-ca.pem");
107 * yield* Cloudflare.Acm.CustomTrustStore("OriginRootCa", {
108 * zoneId: zone.zoneId,
109 * certificate: pem,
110 * });
111 * ```
112 *
113 * @see https://developers.cloudflare.com/api/resources/acm/
114 *
115 * @resource
116 * @product ACM
117 * @category SSL/TLS & Certificates
118 */
119export const CustomTrustStore = Resource<CustomTrustStore>(TypeId);
120
121/**
122 * Returns true if the given value is a CustomTrustStore resource.
123 */
124export const isCustomTrustStore = (value: unknown): value is CustomTrustStore =>
125 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
126
127export const CustomTrustStoreProvider = () =>
128 Provider.succeed(CustomTrustStore, {
129 stables: ["id", "zoneId", "issuer", "signature", "uploadedOn"],
130
131 list: Effect.fn(function* () {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133 // Trust stores live inside a zone (`/zones/{zone_id}/acm/...`) with
134 // no account-wide collection API — enumerate every zone and list
135 // within each, paginating exhaustively.
136 const zones = yield* listAllZones(accountId);
137 const rows = yield* Effect.forEach(
138 zones,
139 (zone) =>
140 acm.listCustomTrustStores.pages({ zoneId: zone.id }).pipe(
141 Stream.runCollect,
142 Effect.map((chunk) =>
143 Array.from(chunk).flatMap((page) =>
144 (page.result ?? [])
145 .filter((cert) => !isGoneStatus(cert.status))
146 .map((cert) => toAttributes(zone.id, cert)),
147 ),
148 ),
149 // Zones without the Advanced Certificate Manager add-on reject
150 // the route with the typed entitlement tag — skip them.
151 Effect.catchTag("AdvancedCertificateManagerRequired", () =>
152 Effect.succeed([] as CustomTrustStoreAttributes[]),
153 ),
154 ),
155 { concurrency: 10 },
156 );
157 return rows.flat();
158 }),
159
160 diff: Effect.fn(function* ({ olds, news, output }) {
161 if (!isResolved(news)) return undefined;
162 // The certificate is the resource's identity — no update API exists.
163 const oldCertificate = olds?.certificate ?? output?.certificate;
164 if (
165 oldCertificate !== undefined &&
166 normalizePem(oldCertificate) !== normalizePem(news.certificate)
167 ) {
168 return { action: "replace" } as const;
169 }
170 // zoneId is Input<string>; compare only once both sides are concrete.
171 const oldZoneId =
172 output?.zoneId ??
173 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
174 if (
175 oldZoneId !== undefined &&
176 typeof news.zoneId === "string" &&
177 oldZoneId !== news.zoneId
178 ) {
179 return { action: "replace" } as const;
180 }
181 return undefined;
182 }),
183
184 read: Effect.fn(function* ({ output, olds }) {
185 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
186 if (!zoneId) return undefined;
187
188 // Owned path: refresh by our persisted certificate id.
189 if (output?.id) {
190 const observed = yield* getTrustStore(zoneId, output.id);
191 if (observed && !isGoneStatus(observed.status)) {
192 return toAttributes(zoneId, observed);
193 }
194 }
195
196 // Adoption path: a certificate with the same PEM may already exist.
197 // Trust store entries carry no ownership markers, so report it as
198 // `Unowned` and let the engine gate takeover behind the adopt policy.
199 const certificate = output?.certificate ?? olds?.certificate;
200 if (certificate) {
201 const observed = yield* findByCertificate(zoneId, certificate);
202 if (observed) return Unowned(toAttributes(zoneId, observed));
203 }
204 return undefined;
205 }),
206
207 reconcile: Effect.fn(function* ({ news, output }) {
208 // Inputs have been resolved to concrete strings by Plan.
209 const zoneId = news.zoneId as string;
210
211 // 1. Observe — the id cached on `output` is a hint, not a guarantee:
212 // a missing (or async-deleted) certificate falls through to the
213 // PEM scan and then to create.
214 let observed = output?.id
215 ? yield* getTrustStore(zoneId, output.id)
216 : undefined;
217 if (observed && isGoneStatus(observed.status)) observed = undefined;
218
219 // 2. Fall back to scanning the zone for the same PEM body. Ownership
220 // has already been verified upstream — `read` reports foreign
221 // certificates as `Unowned` and the engine gates adoption.
222 if (!observed) {
223 observed = yield* findByCertificate(zoneId, news.certificate);
224 }
225
226 // 3. Ensure — upload when missing. There is no sync step: the
227 // certificate is immutable (diff replaces on any change).
228 if (!observed) {
229 observed = yield* acm.createCustomTrustStore({
230 zoneId,
231 certificate: news.certificate,
232 });
233 }
234
235 return toAttributes(zoneId, observed);
236 }),
237
238 delete: Effect.fn(function* ({ output }) {
239 // Observe first — re-deleting an already-gone certificate is a no-op
240 // (delete must stay idempotent across crashed runs).
241 const observed = yield* getTrustStore(output.zoneId, output.id);
242 if (!observed || isGoneStatus(observed.status)) return;
243 yield* acm
244 .deleteCustomTrustStore({
245 zoneId: output.zoneId,
246 customOriginTrustStoreId: output.id,
247 })
248 .pipe(
249 Effect.catchTag(
250 ["CustomTrustStoreNotFound", "InvalidObjectIdentifier"],
251 () => Effect.void,
252 ),
253 );
254 }),
255 });
256
257// ---------------------------------------------------------------------------
258// Helpers
259// ---------------------------------------------------------------------------
260
261type ObservedTrustStore =
262 | acm.GetCustomTrustStoreResponse
263 | acm.CreateCustomTrustStoreResponse
264 | acm.ListCustomTrustStoresResponse["result"][number];
265
266/**
267 * Deletion is asynchronous — a certificate lingers in `pending_deletion`
268 * (then `deleted`) after a successful DELETE. Treat both as "gone" so
269 * observation does not resurrect a dying certificate.
270 */
271const isGoneStatus = (status: CustomTrustStoreStatus): boolean =>
272 status === "pending_deletion" || status === "deleted";
273
274/**
275 * Read a trust store certificate by id, mapping "gone"
276 * (`CustomTrustStoreNotFound` / `InvalidObjectIdentifier`) to `undefined`.
277 */
278const getTrustStore = (zoneId: string, id: string) =>
279 acm.getCustomTrustStore({ zoneId, customOriginTrustStoreId: id }).pipe(
280 Effect.map((cert): ObservedTrustStore | undefined => cert),
281 Effect.catchTag(
282 ["CustomTrustStoreNotFound", "InvalidObjectIdentifier"],
283 () => Effect.succeed(undefined),
284 ),
285 );
286
287/**
288 * Find a live certificate with the same PEM body in the zone's trust
289 * store. PEMs are compared after whitespace normalization so formatting
290 * differences (trailing newline, CRLF) don't defeat the match.
291 */
292const findByCertificate = (zoneId: string, certificate: string) =>
293 acm.listCustomTrustStores.items({ zoneId }).pipe(
294 Stream.runCollect,
295 Effect.map((chunk) =>
296 Array.from(chunk).find(
297 (cert): cert is ObservedTrustStore =>
298 !isGoneStatus(cert.status) &&
299 normalizePem(cert.certificate) === normalizePem(certificate),
300 ),
301 ),
302 );
303
304const normalizePem = (pem: string): string => pem.replace(/\r\n/g, "\n").trim();
305
306const toAttributes = (
307 zoneId: string,
308 cert: ObservedTrustStore,
309): CustomTrustStoreAttributes => ({
310 id: cert.id,
311 zoneId,
312 certificate: cert.certificate,
313 expiresOn: cert.expiresOn,
314 issuer: cert.issuer,
315 signature: cert.signature,
316 status: cert.status,
317 uploadedOn: cert.uploadedOn,
318 updatedAt: cert.updatedAt,
319});