1import * as schemaValidation from "@distilled.cloud/cloudflare/schema-validation";
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 "../Zone/lookup.ts";
10
11const TypeId = "Cloudflare.SchemaValidation.Settings" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * Mitigation action applied when a request does not conform to a schema:
16 * `log` records the request, `block` denies it, `none` does nothing.
17 */
18export type MitigationAction = "none" | "log" | "block";
19
20export interface SettingsProps {
21 /**
22 * Zone the settings belong to. Stable — changing the zone triggers a
23 * replacement (the old zone's settings are restored to the values they
24 * had before Alchemy managed them).
25 */
26 zoneId: string;
27 /**
28 * The default mitigation action used when a request does not conform to
29 * an enabled schema: `log` records it, `block` denies it, `none` does
30 * nothing. `log` and `block` may be plan-gated (API Shield entitlement).
31 */
32 validationDefaultMitigationAction: MitigationAction;
33 /**
34 * Zone-wide kill switch. When set to `"none"`, schema validation is
35 * skipped entirely for every request, overriding both the zone default
36 * and any per-operation settings. `null` clears the override.
37 * @default null
38 */
39 validationOverrideMitigationAction?: "none" | null;
40}
41
42export interface SettingsAttributes {
43 /** Zone the settings belong to. */
44 zoneId: string;
45 /** The default mitigation action for non-conforming requests. */
46 validationDefaultMitigationAction: MitigationAction;
47 /** The zone-wide override (`"none"` = validation disabled), if set. */
48 validationOverrideMitigationAction: "none" | (string & {}) | null;
49 /**
50 * The default action the zone had before Alchemy first managed these
51 * settings. Restored on destroy.
52 */
53 initialDefaultMitigationAction: MitigationAction;
54 /**
55 * The override the zone had before Alchemy first managed these settings.
56 * Restored on destroy.
57 */
58 initialOverrideMitigationAction: "none" | (string & {}) | null;
59}
60
61export type Settings = Resource<
62 TypeId,
63 SettingsProps,
64 SettingsAttributes,
65 never,
66 Providers
67>;
68
69/**
70 * Zone-level schema validation settings
71 * (`/zones/{zone_id}/schema_validation/settings`) — the default mitigation
72 * action applied to requests that do not conform to an enabled schema, plus
73 * an optional zone-wide kill switch.
74 *
75 * The settings are a zone singleton: they always exist (Cloudflare default
76 * is `none`), so this resource never creates or deletes anything physical.
77 * Reconcile PUTs the desired state when the observed state differs; destroy
78 * restores the values the zone had before Alchemy first managed them.
79 *
80 * The `log` action is plan-gated (API Shield entitlement) on some zones —
81 * setting it there fails with the typed `UnentitledMitigationAction` error.
82 * ### Managing the zone default
83 * **Example:** Block non-conforming requests
84 * ```typescript
85 * yield* Cloudflare.SchemaValidation.Settings("Validation", {
86 * zoneId: zone.zoneId,
87 * validationDefaultMitigationAction: "block",
88 * });
89 * ```
90 *
91 * ### Kill switch
92 * **Example:** Temporarily disable validation zone-wide
93 * ```typescript
94 * yield* Cloudflare.SchemaValidation.Settings("Validation", {
95 * zoneId: zone.zoneId,
96 * validationDefaultMitigationAction: "block",
97 * // overrides every schema and per-operation setting:
98 * validationOverrideMitigationAction: "none",
99 * });
100 * ```
101 *
102 * @see https://developers.cloudflare.com/api-shield/security/schema-validation/
103 *
104 * @resource
105 * @product Schema Validation
106 * @category Application Security
107 */
108export const Settings = Resource<Settings>(TypeId);
109
110/**
111 * Returns true if the given value is a Settings resource.
112 */
113export const isSettings = (value: unknown): value is Settings =>
114 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
115
116export const SettingsProvider = () =>
117 Provider.succeed(Settings, {
118 nuke: { singleton: true },
119 stables: [
120 "zoneId",
121 "initialDefaultMitigationAction",
122 "initialOverrideMitigationAction",
123 ],
124
125 list: Effect.fn(function* () {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 // No account-wide API for this zone singleton — enumerate every
128 // zone in the account and read its setting (every zone has one,
129 // defaulting to `none`).
130 const allZones = yield* listAllZones(accountId);
131 const rows = yield* Effect.forEach(
132 allZones.map((zone) => zone.id),
133 (zoneId) =>
134 schemaValidation.getSetting({ zoneId }).pipe(
135 // A cold read adopts freely; the observed state is the
136 // initial state (nothing has been managed yet).
137 Effect.map((observed) =>
138 toAttributes(zoneId, observed, observedState(observed)),
139 ),
140 // A scoped token may lack access to some zones; skip them.
141 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
142 ),
143 { concurrency: 10 },
144 );
145 return rows.filter((row): row is SettingsAttributes => row !== undefined);
146 }),
147
148 diff: Effect.fn(function* ({ olds = {}, news, output }) {
149 const o = olds as SettingsProps;
150 const n = news as SettingsProps;
151 // zoneId is Input<string>; compare only once both sides are concrete.
152 const oldZoneId =
153 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
154 if (
155 oldZoneId !== undefined &&
156 typeof n.zoneId === "string" &&
157 oldZoneId !== n.zoneId
158 ) {
159 return { action: "replace" } as const;
160 }
161 return undefined;
162 }),
163
164 read: Effect.fn(function* ({ output, olds }) {
165 const zoneId =
166 output?.zoneId ??
167 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
168 if (zoneId === undefined) return undefined;
169 const observed = yield* schemaValidation.getSetting({ zoneId });
170 // The settings are a singleton that always exists with a Cloudflare
171 // default — there is nothing to "own", so a cold read adopts freely
172 // (never `Unowned`). The observed values at adoption time become the
173 // initial values restored on destroy.
174 return toAttributes(
175 zoneId,
176 observed,
177 output !== undefined
178 ? {
179 defaultAction: output.initialDefaultMitigationAction,
180 overrideAction: output.initialOverrideMitigationAction,
181 }
182 : observedState(observed),
183 );
184 }),
185
186 reconcile: Effect.fn(function* ({ news, output }) {
187 // Inputs have been resolved to concrete strings by Plan.
188 const zoneId = news.zoneId as string;
189 const desired = {
190 defaultAction: news.validationDefaultMitigationAction,
191 overrideAction: news.validationOverrideMitigationAction ?? null,
192 };
193
194 // 1. Observe — the settings always exist; read the live state.
195 const observed = yield* schemaValidation.getSetting({ zoneId });
196
197 // 2. Capture — the pre-management values, restored on destroy.
198 // `output` (including an adoption read) already carries them;
199 // otherwise this is our first touch and the observed state is the
200 // zone's original.
201 const initial =
202 output !== undefined
203 ? {
204 defaultAction: output.initialDefaultMitigationAction,
205 overrideAction: output.initialOverrideMitigationAction,
206 }
207 : observedState(observed);
208
209 // 3. Sync — PUT the full desired state only when it differs.
210 const current = observedState(observed);
211 if (
212 current.defaultAction === desired.defaultAction &&
213 current.overrideAction === desired.overrideAction
214 ) {
215 return toAttributes(zoneId, observed, initial);
216 }
217 const updated = yield* schemaValidation.putSetting({
218 zoneId,
219 validationDefaultMitigationAction: desired.defaultAction,
220 validationOverrideMitigationAction: desired.overrideAction,
221 });
222 return toAttributes(zoneId, updated, initial);
223 }),
224
225 delete: Effect.fn(function* ({ output }) {
226 const {
227 zoneId,
228 initialDefaultMitigationAction,
229 initialOverrideMitigationAction,
230 } = output;
231 // The singleton cannot be deleted — restore the pre-management
232 // state. Skip the call when it already matches (idempotent re-delete
233 // after a crashed run).
234 const observed = yield* schemaValidation.getSetting({ zoneId });
235 const current = observedState(observed);
236 if (
237 current.defaultAction === initialDefaultMitigationAction &&
238 current.overrideAction === initialOverrideMitigationAction
239 ) {
240 return;
241 }
242 yield* schemaValidation.putSetting({
243 zoneId,
244 validationDefaultMitigationAction: initialDefaultMitigationAction,
245 validationOverrideMitigationAction: initialOverrideMitigationAction,
246 });
247 yield* Effect.logInfo(
248 "Cloudflare schema validation settings are a zone singleton and cannot be deleted; restored the pre-management values instead.",
249 );
250 }),
251 });
252
253type SettingResponse =
254 | schemaValidation.GetSettingResponse
255 | schemaValidation.PutSettingResponse;
256
257const observedState = (setting: SettingResponse) => ({
258 // Distilled widens the generated enum to an open union (`string & {}`).
259 defaultAction: setting.validationDefaultMitigationAction as MitigationAction,
260 overrideAction: setting.validationOverrideMitigationAction ?? null,
261});
262
263const toAttributes = (
264 zoneId: string,
265 setting: SettingResponse,
266 initial: {
267 defaultAction: MitigationAction;
268 overrideAction: "none" | (string & {}) | null;
269 },
270): SettingsAttributes => {
271 const current = observedState(setting);
272 return {
273 zoneId,
274 validationDefaultMitigationAction: current.defaultAction,
275 validationOverrideMitigationAction: current.overrideAction,
276 initialDefaultMitigationAction: initial.defaultAction,
277 initialOverrideMitigationAction: initial.overrideAction,
278 };
279};