1import * as zones from "@distilled.cloud/cloudflare/zones";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9import { listAllZones } from "./lookup.ts";
10
11const TypeId = "Cloudflare.Zone.Setting" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * Identifier of a Cloudflare zone setting — every value Cloudflare
16 * recognises on `/zones/{zone_id}/settings/{settingId}`. The open
17 * `(string & {})` tail keeps the type forward-compatible with settings
18 * Cloudflare adds later.
19 */
20export type SettingId =
21 | "0rtt"
22 | "advanced_ddos"
23 | "aegis"
24 | "always_online"
25 | "always_use_https"
26 | "automatic_https_rewrites"
27 | "automatic_platform_optimization"
28 | "brotli"
29 | "browser_cache_ttl"
30 | "browser_check"
31 | "cache_level"
32 | "challenge_ttl"
33 | "china_network_enabled"
34 | "ciphers"
35 | "cname_flattening"
36 | "content_converter"
37 | "development_mode"
38 | "early_hints"
39 | "edge_cache_ttl"
40 | "email_obfuscation"
41 | "h2_prioritization"
42 | "hotlink_protection"
43 | "http2"
44 | "http3"
45 | "image_resizing"
46 | "ip_geolocation"
47 | "ipv6"
48 | "max_upload"
49 | "min_tls_version"
50 | "mirage"
51 | "nel"
52 | "opportunistic_encryption"
53 | "opportunistic_onion"
54 | "orange_to_orange"
55 | "origin_error_page_pass_thru"
56 | "origin_h2_max_streams"
57 | "origin_max_http_version"
58 | "polish"
59 | "prefetch_preload"
60 | "privacy_pass"
61 | "proxy_read_timeout"
62 | "pseudo_ipv4"
63 | "redirects_for_ai_training"
64 | "replace_insecure_js"
65 | "response_buffering"
66 | "rocket_loader"
67 | "search_for_agents"
68 | "security_header"
69 | "security_level"
70 | "server_side_exclude"
71 | "sha1_support"
72 | "sort_query_string_for_cache"
73 | "ssl"
74 | "tls_1_2_only"
75 | "tls_1_3"
76 | "tls_client_auth"
77 | "transformations"
78 | "transformations_allowed_origins"
79 | "true_client_ip_header"
80 | "waf"
81 | "webp"
82 | "websockets"
83 | (string & {});
84
85/**
86 * The concrete, enumerable members of {@link SettingId}. Cloudflare has no
87 * "list all settings" operation in the distilled `zones` service (only a
88 * per-setting `getSetting`), so `list()` fans out a `getSetting` over this
89 * known set on every zone. Forward-compatible settings (the `(string & {})`
90 * tail) cannot be enumerated and are skipped by definition.
91 */
92const KNOWN_ZONE_SETTING_IDS = [
93 "0rtt",
94 "advanced_ddos",
95 "aegis",
96 "always_online",
97 "always_use_https",
98 "automatic_https_rewrites",
99 "automatic_platform_optimization",
100 "brotli",
101 "browser_cache_ttl",
102 "browser_check",
103 "cache_level",
104 "challenge_ttl",
105 "china_network_enabled",
106 "ciphers",
107 "cname_flattening",
108 "content_converter",
109 "development_mode",
110 "early_hints",
111 "edge_cache_ttl",
112 "email_obfuscation",
113 "h2_prioritization",
114 "hotlink_protection",
115 "http2",
116 "http3",
117 "image_resizing",
118 "ip_geolocation",
119 "ipv6",
120 "max_upload",
121 "min_tls_version",
122 "mirage",
123 "nel",
124 "opportunistic_encryption",
125 "opportunistic_onion",
126 "orange_to_orange",
127 "origin_error_page_pass_thru",
128 "origin_h2_max_streams",
129 "origin_max_http_version",
130 "polish",
131 "prefetch_preload",
132 "privacy_pass",
133 "proxy_read_timeout",
134 "pseudo_ipv4",
135 "redirects_for_ai_training",
136 "replace_insecure_js",
137 "response_buffering",
138 "rocket_loader",
139 "search_for_agents",
140 "security_header",
141 "security_level",
142 "server_side_exclude",
143 "sha1_support",
144 "sort_query_string_for_cache",
145 "ssl",
146 "tls_1_2_only",
147 "tls_1_3",
148 "tls_client_auth",
149 "transformations",
150 "transformations_allowed_origins",
151 "true_client_ip_header",
152 "waf",
153 "webp",
154 "websockets",
155] as const satisfies readonly SettingId[];
156
157export type SettingProps = {
158 /**
159 * Zone the setting belongs to. Stable — changing the zone triggers a
160 * replacement (the old zone's setting is restored to the value it had
161 * before Alchemy managed it).
162 */
163 zoneId: string;
164 /**
165 * Which zone setting to manage (e.g. `always_online`,
166 * `browser_cache_ttl`, `min_tls_version`). Stable — the setting id is
167 * the resource's identity, so changing it triggers a replacement.
168 *
169 * Declared as plain `string` (narrowed to {@link SettingId}) so
170 * `diff` can compare without resolving an `Input`.
171 */
172 settingId: SettingId;
173 /**
174 * Desired value of the setting. The shape depends on `settingId` —
175 * on/off toggles take `"on"`/`"off"`, `browser_cache_ttl` takes a
176 * number of seconds, structured settings (e.g. `ciphers`,
177 * `security_header`) take arrays/objects.
178 *
179 * Mutable — patched in place.
180 */
181 value: unknown;
182};
183
184export type SettingAttributes = {
185 /** Zone the setting belongs to. */
186 zoneId: string;
187 /** The managed setting's identifier. */
188 settingId: string;
189 /** Resolved current value of the setting. */
190 value: unknown;
191 /**
192 * Whether the setting can be modified on the zone's current plan
193 * (`false` means the setting is plan-gated).
194 */
195 editable: boolean | undefined;
196 /** When the setting was last modified, if Cloudflare reports it. */
197 modifiedOn: string | undefined;
198 /**
199 * The value the setting had before Alchemy first patched it. Restored
200 * on destroy, so deleting the resource puts the zone back the way it
201 * was found.
202 */
203 initialValue: unknown;
204};
205
206export type Setting = Resource<
207 TypeId,
208 SettingProps,
209 SettingAttributes,
210 never,
211 Providers
212>;
213
214/**
215 * A single Cloudflare zone setting (`/zones/{zone_id}/settings/{settingId}`)
216 * pinned to a desired value.
217 *
218 * Zone settings are singletons — every setting always exists on every zone
219 * (with a Cloudflare default), so this resource never creates or deletes
220 * anything physical. Reconcile patches the setting when the observed value
221 * differs from the desired one; destroy restores the value the setting had
222 * before Alchemy first managed it (captured as `initialValue`).
223 *
224 * Many settings are plan-gated (`editable: false` on lower plans — e.g.
225 * `image_resizing`, `polish` need Pro+; `advanced_ddos` is Enterprise).
226 * Patching a non-editable setting fails with Cloudflare's "setting not
227 * editable" error.
228 * ### Toggle settings
229 * **Example:** Force HTTPS on the whole zone
230 * ```typescript
231 * yield* Cloudflare.Zone.Setting("AlwaysUseHttps", {
232 * zoneId: zone.zoneId,
233 * settingId: "always_use_https",
234 * value: "on",
235 * });
236 * ```
237 *
238 * **Example:** Disable Always Online
239 * ```typescript
240 * yield* Cloudflare.Zone.Setting("AlwaysOnline", {
241 * zoneId: zone.zoneId,
242 * settingId: "always_online",
243 * value: "off",
244 * });
245 * ```
246 *
247 * ### Numeric settings
248 * **Example:** Browser cache TTL of one hour
249 * ```typescript
250 * yield* Cloudflare.Zone.Setting("BrowserCacheTtl", {
251 * zoneId: zone.zoneId,
252 * settingId: "browser_cache_ttl",
253 * value: 3600,
254 * });
255 * ```
256 *
257 * ### TLS settings
258 * **Example:** Require at least TLS 1.2
259 * ```typescript
260 * yield* Cloudflare.Zone.Setting("MinTls", {
261 * zoneId: zone.zoneId,
262 * settingId: "min_tls_version",
263 * value: "1.2",
264 * });
265 * ```
266 *
267 * @see https://developers.cloudflare.com/api/resources/zones/subresources/settings/
268 *
269 * @resource
270 * @product Zones
271 * @category Domains & DNS
272 */
273export const Setting = Resource<Setting>(TypeId);
274
275/**
276 * Returns true if the given value is a Setting resource.
277 */
278export const isSetting = (value: unknown): value is Setting =>
279 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
280
281export const SettingProvider = () =>
282 Provider.succeed(Setting, {
283 stables: ["zoneId", "settingId", "initialValue"],
284
285 diff: Effect.fn(function* ({ olds = {}, news, output }) {
286 const o = olds as SettingProps;
287 const n = news as SettingProps;
288 // settingId is the resource's identity.
289 const oldSettingId = output?.settingId ?? o.settingId;
290 if (oldSettingId !== undefined && oldSettingId !== n.settingId) {
291 return { action: "replace" } as const;
292 }
293 // zoneId is Input<string>; compare only once both sides are concrete.
294 const oldZoneId =
295 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
296 if (
297 oldZoneId !== undefined &&
298 typeof n.zoneId === "string" &&
299 oldZoneId !== n.zoneId
300 ) {
301 return { action: "replace" } as const;
302 }
303 return undefined;
304 }),
305
306 read: Effect.fn(function* ({ output, olds }) {
307 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
308 const settingId = output?.settingId ?? olds?.settingId;
309 if (!zoneId || !settingId) return undefined;
310 const observed = yield* zones.getSetting({ zoneId, settingId }).pipe(
311 // Zone deleted out-of-band — the setting is gone with it.
312 Effect.catchTag("InvalidZoneIdentifier", () =>
313 Effect.succeed(undefined),
314 ),
315 );
316 if (observed === undefined) return undefined;
317 // Settings are singletons that always exist with a Cloudflare
318 // default — there is nothing to "own", so a cold read adopts
319 // freely (never `Unowned`). The observed value at adoption time
320 // becomes the `initialValue` restored on destroy.
321 const initialValue =
322 output !== undefined ? output.initialValue : settingValue(observed);
323 return toAttributes(zoneId, settingId, observed, initialValue);
324 }),
325
326 reconcile: Effect.fn(function* ({ news, output }) {
327 // Inputs have been resolved to concrete strings by Plan.
328 const zoneId = news.zoneId as string;
329 const settingId = news.settingId;
330
331 // 1. Observe — the setting always exists; read its live value.
332 const observed = yield* zones.getSetting({ zoneId, settingId });
333
334 // 2. Capture — the pre-management value, restored on destroy.
335 // `output` (including an adoption read) already carries it;
336 // otherwise this is our first touch and the observed value is
337 // the zone's original.
338 const initialValue =
339 output !== undefined ? output.initialValue : settingValue(observed);
340
341 // 3. Sync — patch only when the observed value differs.
342 if (deepValueEquals(settingValue(observed), news.value)) {
343 return toAttributes(zoneId, settingId, observed, initialValue);
344 }
345 const patched = yield* zones.patchSetting({
346 zoneId,
347 settingId,
348 value: news.value,
349 });
350 return toAttributes(zoneId, settingId, patched, initialValue);
351 }),
352
353 delete: Effect.fn(function* ({ output }) {
354 const { zoneId, settingId, initialValue } = output;
355 // Observe — if the zone itself is gone, so is the setting.
356 const observed = yield* zones
357 .getSetting({ zoneId, settingId })
358 .pipe(
359 Effect.catchTag("InvalidZoneIdentifier", () =>
360 Effect.succeed(undefined),
361 ),
362 );
363 if (observed === undefined) return;
364 // Restore the pre-management value; skip the call when it already
365 // matches (idempotent re-delete after a crashed run).
366 if (deepValueEquals(settingValue(observed), initialValue)) return;
367 yield* zones
368 .patchSetting({ zoneId, settingId, value: initialValue })
369 .pipe(Effect.catchTag("InvalidZoneIdentifier", () => Effect.void));
370 }),
371
372 // Zone settings are keyed by (zoneId, settingId): every setting exists on
373 // every zone with a Cloudflare default. There is no account-wide list, so
374 // enumerate every zone via `listAllZones` and read each known setting with
375 // the same `getSetting` call `read` uses — emitting one Attributes per
376 // (zone, setting), exactly matching `read`'s keying.
377 //
378 // The distilled `zones` service exposes only a per-setting `getSetting`
379 // (no bulk `/zones/{id}/settings` list op), so we fan out across the known
380 // setting ids. Adding a bulk-list operation to distilled would collapse
381 // each zone's N gets into one call — see the agent report's neededPatch.
382 list: Effect.fn(function* () {
383 const { accountId } = yield* yield* CloudflareEnvironment;
384 const allZones = yield* listAllZones(accountId);
385 const pairs = allZones.flatMap((zone) =>
386 KNOWN_ZONE_SETTING_IDS.map((settingId) => ({
387 zoneId: zone.id,
388 settingId,
389 })),
390 );
391 const rows = yield* Effect.forEach(
392 pairs,
393 ({ zoneId, settingId }) =>
394 zones.getSetting({ zoneId, settingId }).pipe(
395 Effect.map((observed) =>
396 // Unmanaged at discovery time, so the observed value is the
397 // setting's pre-management value (mirrors a cold `read`).
398 toAttributes(zoneId, settingId, observed, settingValue(observed)),
399 ),
400 // Zone removed out-of-band or the setting is plan-gated / not
401 // exposed to this token — skip it rather than fail the listing.
402 Effect.catchTag(["InvalidZoneIdentifier", "Forbidden"], () =>
403 Effect.succeed<SettingAttributes | undefined>(undefined),
404 ),
405 // A handful of structured settings (e.g.
406 // `automatic_platform_optimization`) can return a scalar value
407 // (`"off"`) that distilled's `GetSettingResponse` union doesn't
408 // model, surfacing as an untyped `CloudflareHttpError` (status
409 // 200, "Schema decode failed"). The Cloudflare patch system only
410 // types errors, not response schemas, so this can't be patched
411 // here — skip the undecodable setting rather than failing the
412 // whole listing. See the agent report's neededPatch (widen the
413 // `GetSettingResponse` member to accept the scalar form).
414 Effect.catch(() =>
415 Effect.succeed<SettingAttributes | undefined>(undefined),
416 ),
417 ),
418 { concurrency: 10 },
419 );
420 return rows.filter((row): row is SettingAttributes => row !== undefined);
421 }),
422 });
423
424/**
425 * Pull the `value` out of a distilled setting response. Every member of the
426 * response union carries `value` (a couple type it optional), so widen
427 * rather than switch on all sixty setting ids.
428 */
429const settingValue = (
430 setting: zones.GetSettingResponse | zones.PatchSettingResponse,
431): unknown => (setting as { value?: unknown }).value;
432
433const toAttributes = (
434 zoneId: string,
435 settingId: string,
436 setting: zones.GetSettingResponse | zones.PatchSettingResponse,
437 initialValue: unknown,
438): SettingAttributes => {
439 const s = setting as {
440 value?: unknown;
441 editable?: boolean | null;
442 modifiedOn?: string | null;
443 };
444 return {
445 zoneId,
446 settingId,
447 value: s.value,
448 editable: s.editable ?? undefined,
449 modifiedOn: s.modifiedOn ?? undefined,
450 initialValue,
451 };
452};
453
454/**
455 * Structural equality for setting values — primitives, arrays, and plain
456 * objects (`null`-tolerant). Setting values are plain JSON, so this is
457 * sufficient and avoids key-order false negatives that `JSON.stringify`
458 * comparison would produce.
459 */
460const deepValueEquals = (a: unknown, b: unknown): boolean => {
461 if (a === b) return true;
462 if (a === null || b === null) return false;
463 if (Array.isArray(a) || Array.isArray(b)) {
464 if (!Array.isArray(a) || !Array.isArray(b)) return false;
465 if (a.length !== b.length) return false;
466 return a.every((v, i) => deepValueEquals(v, b[i]));
467 }
468 if (typeof a === "object" && typeof b === "object") {
469 const ak = Object.keys(a as Record<string, unknown>);
470 const bk = Object.keys(b as Record<string, unknown>);
471 if (ak.length !== bk.length) return false;
472 return ak.every((k) =>
473 deepValueEquals(
474 (a as Record<string, unknown>)[k],
475 (b as Record<string, unknown>)[k],
476 ),
477 );
478 }
479 return false;
480};