1import * as acm from "@distilled.cloud/cloudflare/acm";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const TypeId = "Cloudflare.Acm.TotalTls" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * The Certificate Authority Total TLS certificates are issued through.
18 */
19export type TotalTlsCertificateAuthority =
20 | "google"
21 | "lets_encrypt"
22 | "ssl_com";
23
24export interface TotalTlsProps {
25 /**
26 * Zone whose Total TLS setting is managed. Stable — changing the zone
27 * triggers a replacement (the old zone's setting is restored to the
28 * state it had before Alchemy managed it).
29 */
30 zoneId: string;
31 /**
32 * Whether Total TLS is enabled. When enabled, Cloudflare orders a
33 * hostname-specific TLS certificate for every proxied A, AAAA, or CNAME
34 * record in the zone. Mutable — updated in place.
35 */
36 enabled: boolean;
37 /**
38 * The Certificate Authority that Total TLS certificates will be issued
39 * through. When omitted, Cloudflare picks one. Mutable — updated in
40 * place.
41 */
42 certificateAuthority?: TotalTlsCertificateAuthority;
43}
44
45export interface TotalTlsAttributes {
46 /** Zone the setting belongs to — this is the singleton's identity. */
47 zoneId: string;
48 /** Whether Total TLS is currently enabled on the zone. */
49 enabled: boolean;
50 /**
51 * The Certificate Authority issuing Total TLS certificates, if
52 * Cloudflare reports one.
53 */
54 certificateAuthority: string | undefined;
55 /**
56 * The validity period in days for certificates ordered via Total TLS
57 * (currently always 90), if Cloudflare reports it.
58 */
59 validityPeriod: number | undefined;
60 /**
61 * Whether Total TLS was enabled before Alchemy first touched the zone.
62 * Restored on destroy, so deleting the resource puts the zone back the
63 * way it was found.
64 */
65 initialEnabled: boolean;
66 /**
67 * The Certificate Authority configured before Alchemy first touched the
68 * zone, restored on destroy alongside `initialEnabled`.
69 */
70 initialCertificateAuthority: string | undefined;
71}
72
73export type TotalTls = Resource<
74 TypeId,
75 TotalTlsProps,
76 TotalTlsAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * The Total TLS setting of a Cloudflare zone
83 * (`/zones/{zone_id}/acm/total_tls`).
84 *
85 * Total TLS orders a hostname-specific TLS certificate for every proxied
86 * A, AAAA, or CNAME record in the zone, covering deep subdomains that the
87 * universal certificate's single-level wildcard cannot. The setting is a
88 * zone **singleton** — it always exists (default disabled), so this
89 * resource never creates or deletes anything physical. Reconcile posts the
90 * setting when the observed state differs from the desired one; destroy
91 * restores the state the zone had before Alchemy first managed it
92 * (captured as `initialEnabled` / `initialCertificateAuthority`).
93 *
94 * **Entitlement-gated**: configuring Total TLS requires the Advanced
95 * Certificate Manager add-on on the zone. Without it, every write fails
96 * with the typed `AdvancedCertificateManagerRequired` (code 1450) error
97 * (reads succeed and report `enabled: false`).
98 *
99 * Only one `TotalTls` resource per zone makes sense — two instances
100 * managing the same zone would fight over the singleton.
101 * ### Managing Total TLS
102 * **Example:** Enable Total TLS on a zone
103 * ```typescript
104 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
105 *
106 * yield* Cloudflare.Acm.TotalTls("TotalTls", {
107 * zoneId: zone.zoneId,
108 * enabled: true,
109 * });
110 * ```
111 *
112 * **Example:** Pin the issuing Certificate Authority
113 * ```typescript
114 * yield* Cloudflare.Acm.TotalTls("TotalTls", {
115 * zoneId: zone.zoneId,
116 * enabled: true,
117 * certificateAuthority: "lets_encrypt",
118 * });
119 * ```
120 *
121 * @see https://developers.cloudflare.com/ssl/edge-certificates/additional-options/total-tls/
122 *
123 * @resource
124 * @product ACM
125 * @category SSL/TLS & Certificates
126 */
127export const TotalTls = Resource<TotalTls>(TypeId);
128
129/**
130 * Returns true if the given value is a TotalTls resource.
131 */
132export const isTotalTls = (value: unknown): value is TotalTls =>
133 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
134
135export const TotalTlsProvider = () =>
136 Provider.succeed(TotalTls, {
137 nuke: { singleton: true },
138 stables: ["zoneId", "initialEnabled", "initialCertificateAuthority"],
139
140 list: Effect.fn(function* () {
141 const { accountId } = yield* yield* CloudflareEnvironment;
142 // No account-wide API for this zone singleton — enumerate every
143 // zone in the account and read its setting (every zone has one,
144 // defaulting to disabled; reads succeed even without the ACM
145 // entitlement).
146 const allZones = yield* listAllZones(accountId);
147 const rows = yield* Effect.forEach(
148 allZones.map((zone) => zone.id),
149 (zoneId) =>
150 acm.getTotalTl({ zoneId }).pipe(
151 Effect.map((observed) =>
152 toAttributes(
153 zoneId,
154 observed,
155 initialStateOf(undefined, observed),
156 ),
157 ),
158 // Zone deleted out-of-band between enumeration and read.
159 Effect.catchTag("InvalidObjectIdentifier", () =>
160 Effect.succeed(undefined),
161 ),
162 ),
163 { concurrency: 10 },
164 );
165 return rows.filter((row): row is TotalTlsAttributes => row !== undefined);
166 }),
167
168 diff: Effect.fn(function* ({ olds, news, output }) {
169 if (!isResolved(news)) return undefined;
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 const observed = yield* acm.getTotalTl({ zoneId }).pipe(
188 // Zone deleted out-of-band — the setting is gone with it.
189 Effect.catchTag("InvalidObjectIdentifier", () =>
190 Effect.succeed(undefined),
191 ),
192 );
193 if (observed === undefined) return undefined;
194 // The setting is a singleton that always exists with a Cloudflare
195 // default — there is nothing to "own", so a cold read adopts freely
196 // (never `Unowned`). The observed state at adoption time becomes the
197 // initial state restored on destroy.
198 return toAttributes(zoneId, observed, initialStateOf(output, observed));
199 }),
200
201 reconcile: Effect.fn(function* ({ news, output }) {
202 // Inputs have been resolved to concrete strings by Plan.
203 const zoneId = news.zoneId as string;
204
205 // 1. Observe — the setting always exists; read its live state.
206 // (Reads succeed even on zones without the ACM entitlement.)
207 const observed = yield* acm.getTotalTl({ zoneId });
208
209 // 2. Capture — the pre-management state, restored on destroy.
210 // `output` (including an adoption read) already carries it;
211 // otherwise this is our first touch and the observed state is
212 // the zone's original.
213 const initial = initialStateOf(output, observed);
214
215 // 3. Sync — post only when the observed state differs. Zones
216 // without the ACM entitlement fail here with the typed
217 // `AdvancedCertificateManagerRequired` error.
218 if (matchesDesired(observed, news)) {
219 return toAttributes(zoneId, observed, initial);
220 }
221 const updated = yield* acm
222 .updateTotalTl({
223 zoneId,
224 enabled: news.enabled,
225 certificateAuthority: news.certificateAuthority,
226 })
227 .pipe(
228 // A concurrent actor already applied the same state — converge
229 // by re-reading instead of failing.
230 Effect.catchTag("NoStateChange", () => acm.getTotalTl({ zoneId })),
231 // Cloudflare serializes Total TLS jobs per zone; ride out a
232 // previous job finishing (bounded).
233 Effect.retry({
234 while: (e) => e._tag === "PreviousJobInProgress",
235 schedule: Schedule.spaced("3 seconds"),
236 times: 10,
237 }),
238 );
239 return toAttributes(zoneId, updated, initial);
240 }),
241
242 delete: Effect.fn(function* ({ output }) {
243 const { zoneId, initialEnabled, initialCertificateAuthority } = output;
244 // Observe — if the zone itself is gone, so is the setting.
245 const observed = yield* acm
246 .getTotalTl({ zoneId })
247 .pipe(
248 Effect.catchTag("InvalidObjectIdentifier", () =>
249 Effect.succeed(undefined),
250 ),
251 );
252 if (observed === undefined) return;
253 // Restore the pre-management state; skip the call when it already
254 // matches (idempotent re-delete after a crashed run).
255 if (
256 (observed.enabled ?? false) === initialEnabled &&
257 (initialCertificateAuthority === undefined ||
258 (observed.certificateAuthority ?? undefined) ===
259 initialCertificateAuthority)
260 ) {
261 return;
262 }
263 yield* acm
264 .updateTotalTl({
265 zoneId,
266 enabled: initialEnabled,
267 // The request type accepts any string (`(string & {})`), so the
268 // captured initial CA round-trips without a cast.
269 certificateAuthority: initialCertificateAuthority,
270 })
271 .pipe(
272 Effect.catchTag("InvalidObjectIdentifier", () => Effect.void),
273 Effect.catchTag("NoStateChange", () => Effect.void),
274 // The zone lost its ACM entitlement since we configured it —
275 // Cloudflare already refuses Total TLS writes, so there is
276 // nothing left to restore.
277 Effect.catchTag(
278 "AdvancedCertificateManagerRequired",
279 () => Effect.void,
280 ),
281 Effect.retry({
282 while: (e) => e._tag === "PreviousJobInProgress",
283 schedule: Schedule.spaced("3 seconds"),
284 times: 10,
285 }),
286 Effect.asVoid,
287 );
288 }),
289 });
290
291// ---------------------------------------------------------------------------
292// Helpers
293// ---------------------------------------------------------------------------
294
295type ObservedTotalTls = acm.GetTotalTlResponse | acm.UpdateTotalTlResponse;
296
297/**
298 * The pre-management state restored on destroy: carried forward on
299 * `output` once captured, otherwise derived from the first observation.
300 */
301const initialStateOf = (
302 output: TotalTlsAttributes | undefined,
303 observed: ObservedTotalTls,
304): Pick<
305 TotalTlsAttributes,
306 "initialEnabled" | "initialCertificateAuthority"
307> =>
308 output !== undefined
309 ? {
310 initialEnabled: output.initialEnabled,
311 initialCertificateAuthority: output.initialCertificateAuthority,
312 }
313 : {
314 initialEnabled: observed.enabled ?? false,
315 initialCertificateAuthority: observed.certificateAuthority ?? undefined,
316 };
317
318/**
319 * Whether the observed setting already matches the desired props — the
320 * certificate authority only participates when explicitly requested
321 * (Cloudflare picks one otherwise).
322 */
323const matchesDesired = (
324 observed: ObservedTotalTls,
325 news: TotalTlsProps,
326): boolean =>
327 (observed.enabled ?? false) === news.enabled &&
328 (news.certificateAuthority === undefined ||
329 (observed.certificateAuthority ?? undefined) === news.certificateAuthority);
330
331const toAttributes = (
332 zoneId: string,
333 setting: ObservedTotalTls,
334 initial: Pick<
335 TotalTlsAttributes,
336 "initialEnabled" | "initialCertificateAuthority"
337 >,
338): TotalTlsAttributes => ({
339 zoneId,
340 enabled: setting.enabled ?? false,
341 certificateAuthority: setting.certificateAuthority ?? undefined,
342 validityPeriod: setting.validityPeriod ?? undefined,
343 ...initial,
344});