1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
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";
12
13const TypeId = "Cloudflare.Gateway.Certificate" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Deployment status of the certificate on Cloudflare's edge. Gateway TLS
18 * interception can use certificates in the `available` state.
19 */
20export type CertificateBindingStatus =
21 | "pending_deployment"
22 | "available"
23 | "pending_deletion"
24 | "inactive"
25 | (string & {});
26
27export interface CertificateProps {
28 /**
29 * Certificate validity period in days (range: 1–10,950 days / ~30
30 * years). Only settable at creation time — changing it triggers a
31 * replacement (a new certificate is generated).
32 *
33 * @default 1825
34 */
35 validityPeriodDays?: number;
36 /**
37 * Whether the certificate should be activated (deployed to Cloudflare's
38 * edge so Gateway TLS interception can use it). Activation typically
39 * completes within seconds; the provider waits (bounded) for the
40 * `available` binding status. Set `false` to keep or return the
41 * certificate to the `inactive` state.
42 *
43 * @default true
44 */
45 activate?: boolean;
46}
47
48export interface CertificateAttributes {
49 /** UUID of the certificate, assigned by Cloudflare. */
50 certificateId: string;
51 /** Cloudflare account that owns the certificate. */
52 accountId: string;
53 /** Edge deployment status (`available` means usable for interception). */
54 bindingStatus: CertificateBindingStatus | undefined;
55 /** The CA certificate PEM (read-only, generated by Cloudflare). */
56 certificate: string | undefined;
57 /** SHA256 fingerprint of the certificate. */
58 fingerprint: string | undefined;
59 /**
60 * Whether Gateway TLS interception currently uses this certificate.
61 * Configured via the Gateway configuration `certificate` setting, not
62 * on the certificate itself.
63 */
64 inUse: boolean | undefined;
65 /** Organization that issued the certificate. */
66 issuerOrg: string | undefined;
67 /** Certificate kind — Cloudflare-generated (`gateway_managed`) or BYO-PKI (`custom`). */
68 certificateType: string | undefined;
69 /** ISO8601 expiry timestamp. */
70 expiresOn: string | undefined;
71 /** ISO8601 creation timestamp. */
72 createdAt: string | undefined;
73}
74
75export type Certificate = Resource<
76 TypeId,
77 CertificateProps,
78 CertificateAttributes,
79 never,
80 Providers
81>;
82
83/**
84 * A Cloudflare Zero Trust Gateway certificate — a Cloudflare-generated CA
85 * used by Gateway to inspect TLS traffic (HTTPS filtering, antivirus
86 * scanning, browser isolation). The certificate body is generated by
87 * Cloudflare; you only choose the validity period and whether it is
88 * activated (deployed to the edge).
89 *
90 * To make Gateway actually intercept with this certificate, reference its
91 * `certificateId` from the Gateway configuration's `certificate` setting
92 * (see `Cloudflare.Gateway.Configuration`).
93 * ### Creating a Certificate
94 * **Example:** Activated certificate (default)
95 * ```typescript
96 * const cert = yield* Cloudflare.Gateway.Certificate("InspectionCa", {});
97 * // cert.bindingStatus === "available" once deployed to the edge
98 * ```
99 *
100 * **Example:** Short-lived, kept inactive
101 * ```typescript
102 * const cert = yield* Cloudflare.Gateway.Certificate("StagedCa", {
103 * validityPeriodDays: 365,
104 * activate: false,
105 * });
106 * ```
107 *
108 * ### Using the certificate for TLS interception
109 * **Example:** Wire into the Gateway configuration
110 * ```typescript
111 * const cert = yield* Cloudflare.Gateway.Certificate("InspectionCa", {});
112 * yield* Cloudflare.Gateway.Configuration("Gateway", {
113 * settings: {
114 * tlsDecrypt: { enabled: true },
115 * certificate: { id: cert.certificateId },
116 * },
117 * });
118 * ```
119 *
120 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/user-side-certificates/
121 *
122 * @resource
123 * @product Gateway
124 * @category Cloudflare One (Zero Trust)
125 */
126export const Certificate = Resource<Certificate>(TypeId);
127
128/**
129 * Returns true if the given value is a Certificate resource.
130 */
131export const isCertificate = (value: unknown): value is Certificate =>
132 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
133
134export const CertificateProvider = () =>
135 Provider.succeed(Certificate, {
136 stables: [
137 "certificateId",
138 "accountId",
139 "certificate",
140 "fingerprint",
141 "issuerOrg",
142 "certificateType",
143 "expiresOn",
144 "createdAt",
145 ],
146
147 diff: Effect.fn(function* ({ olds = {}, news, output }) {
148 const { accountId } = yield* yield* CloudflareEnvironment;
149 if (!isResolved(news)) return undefined;
150 if ((output?.accountId ?? accountId) !== accountId) {
151 return { action: "replace" } as const;
152 }
153 // The validity period is only settable at creation time.
154 const o = olds as CertificateProps;
155 if (
156 output !== undefined &&
157 (o.validityPeriodDays ?? 1825) !== (news.validityPeriodDays ?? 1825)
158 ) {
159 return { action: "replace" } as const;
160 }
161 return undefined;
162 }),
163
164 read: Effect.fn(function* ({ output }) {
165 const { accountId } = yield* yield* CloudflareEnvironment;
166 const acct = output?.accountId ?? accountId;
167 // Certificates carry no name or tags, so there is no cold lookup —
168 // without a cached id the resource is simply unknown.
169 if (!output?.certificateId) return undefined;
170 const observed = yield* getCertificate(acct, output.certificateId);
171 if (!observed) return undefined;
172 return toAttributes(observed, acct);
173 }),
174
175 // Account-scoped collection (pattern b): enumerate every Gateway
176 // certificate in the ambient account. The list response already carries
177 // the full certificate shape, so each row maps straight to the `read`
178 // Attributes — no per-item hydration is required.
179 list: Effect.fn(function* () {
180 const { accountId } = yield* yield* CloudflareEnvironment;
181 return yield* zeroTrust.listGatewayCertificates.pages({ accountId }).pipe(
182 Stream.runCollect,
183 Effect.map((chunk) =>
184 Array.from(chunk).flatMap((page) =>
185 (page.result ?? [])
186 .map((cert) => toAttributes(cert, accountId))
187 // A certificate bound to the Gateway config (the active
188 // inspection CA) can't be deactivated/deleted while in use
189 // (`GatewayCertificateInUse`), and account-wide teardown does
190 // not remove the singleton Gateway config — skip in-use certs.
191 .filter((cert) => !cert.inUse),
192 ),
193 ),
194 );
195 }),
196
197 reconcile: Effect.fn(function* ({ news, output }) {
198 const { accountId } = yield* yield* CloudflareEnvironment;
199
200 // 1. Observe — the cached id is a hint; a vanished certificate
201 // falls through to create.
202 let observed = output?.certificateId
203 ? yield* getCertificate(accountId, output.certificateId)
204 : undefined;
205
206 // 2. Ensure — generate the CA when missing. New certificates start
207 // out `inactive`.
208 if (!observed) {
209 observed = yield* zeroTrust.createGatewayCertificate({
210 accountId,
211 ...(news.validityPeriodDays !== undefined
212 ? { validityPeriodDays: news.validityPeriodDays }
213 : {}),
214 });
215 }
216 const certificateId = observed.id ?? "";
217
218 // 3. Sync — converge the activation state. Activation/deactivation
219 // transit through pending_deployment / pending_deletion, so poll
220 // (bounded) for the terminal status; in practice this settles in
221 // a few seconds.
222 const wantActive = news.activate ?? true;
223 const status = observed.bindingStatus ?? undefined;
224 if (wantActive && (status === undefined || status === "inactive")) {
225 yield* zeroTrust.activateGatewayCertificate({
226 accountId,
227 certificateId,
228 });
229 } else if (
230 !wantActive &&
231 (status === "available" || status === "pending_deployment")
232 ) {
233 yield* zeroTrust.deactivateGatewayCertificate({
234 accountId,
235 certificateId,
236 });
237 }
238 const desired = wantActive ? "available" : "inactive";
239 const final = yield* waitForStatus(accountId, certificateId, desired);
240
241 return toAttributes(final ?? observed, accountId);
242 }),
243
244 delete: Effect.fn(function* ({ output }) {
245 const { accountId, certificateId } = output;
246 // Observe — a missing certificate means we're done.
247 const observed = yield* getCertificate(accountId, certificateId);
248 if (!observed) return;
249 // Active certificates cannot be deleted (GatewayCertificateInUse,
250 // Cloudflare code 2118) — deactivate first and wait (bounded) for
251 // the `inactive` state.
252 const status = observed.bindingStatus ?? undefined;
253 if (status === "available" || status === "pending_deployment") {
254 yield* zeroTrust
255 .deactivateGatewayCertificate({ accountId, certificateId })
256 .pipe(
257 Effect.catchTag("GatewayCertificateNotFound", () => Effect.void),
258 );
259 yield* waitForStatus(accountId, certificateId, "inactive");
260 }
261 yield* zeroTrust
262 .deleteGatewayCertificate({ accountId, certificateId })
263 .pipe(Effect.catchTag("GatewayCertificateNotFound", () => Effect.void));
264 }),
265 });
266
267type ObservedCertificate =
268 | zeroTrust.GetGatewayCertificateResponse
269 | zeroTrust.CreateGatewayCertificateResponse
270 | zeroTrust.DeactivateGatewayCertificateResponse
271 | zeroTrust.ListGatewayCertificatesResponse["result"][number];
272
273/**
274 * Read a certificate by id, mapping "gone" (`GatewayCertificateNotFound`,
275 * Cloudflare error code 2027) to `undefined`.
276 */
277const getCertificate = (accountId: string, certificateId: string) =>
278 zeroTrust.getGatewayCertificate({ accountId, certificateId }).pipe(
279 Effect.map((c): zeroTrust.GetGatewayCertificateResponse | undefined => c),
280 Effect.catchTag("GatewayCertificateNotFound", () =>
281 Effect.succeed(undefined),
282 ),
283 );
284
285/**
286 * Poll the certificate until it reaches the desired binding status.
287 * Bounded — returns the last observation (which may still be pending)
288 * rather than failing, so eventual consistency never wedges a deploy.
289 */
290const waitForStatus = (
291 accountId: string,
292 certificateId: string,
293 desired: "available" | "inactive",
294) =>
295 getCertificate(accountId, certificateId).pipe(
296 Effect.repeat({
297 schedule: Schedule.spaced("3 seconds"),
298 until: (c) => c === undefined || c.bindingStatus === desired,
299 times: 10,
300 }),
301 );
302
303const toAttributes = (
304 cert: ObservedCertificate,
305 accountId: string,
306): CertificateAttributes => ({
307 certificateId: cert.id ?? "",
308 accountId,
309 bindingStatus: cert.bindingStatus ?? undefined,
310 certificate: cert.certificate ?? undefined,
311 fingerprint: cert.fingerprint ?? undefined,
312 inUse: cert.inUse ?? undefined,
313 issuerOrg: cert.issuerOrg ?? undefined,
314 certificateType: cert.type ?? undefined,
315 expiresOn: cert.expiresOn ?? undefined,
316 createdAt: cert.createdAt ?? undefined,
317});