1import * as schemaValidation from "@distilled.cloud/cloudflare/schema-validation";
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.SchemaValidation.OperationSetting" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Per-operation mitigation action for schema validation: `log` records
19 * non-conforming requests, `block` denies them, `none` does nothing.
20 */
21export type OperationMitigationAction = "log" | "block" | "none";
22
23export interface OperationSettingProps {
24 /**
25 * Zone the operation belongs to.
26 *
27 * Immutable — changing the zone triggers a replacement.
28 */
29 zoneId: string;
30 /**
31 * UUID of the API Shield operation the override applies to (cross-resource
32 * reference to `Cloudflare.ApiShield.Operation`).
33 *
34 * Immutable — the operation is the override's identity, so changing it
35 * triggers a replacement.
36 */
37 operationId: string;
38 /**
39 * The mitigation action applied to this operation, superseding the zone
40 * default just for this operation: `log` records non-conforming requests,
41 * `block` denies them, `none` disables validation for the operation.
42 * Mutable in place (the PUT is a true upsert). `log` may be plan-gated
43 * (API Shield entitlement).
44 */
45 mitigationAction: OperationMitigationAction;
46}
47
48export interface OperationSettingAttributes {
49 /** Zone the operation belongs to. */
50 zoneId: string;
51 /** UUID of the API Shield operation the override applies to. */
52 operationId: string;
53 /** The mitigation action applied to this operation. */
54 mitigationAction: OperationMitigationAction;
55}
56
57export type OperationSetting = Resource<
58 TypeId,
59 OperationSettingProps,
60 OperationSettingAttributes,
61 never,
62 Providers
63>;
64
65/**
66 * A per-operation schema validation override
67 * (`/zones/{zone_id}/schema_validation/settings/operations/{operation_id}`)
68 * — pins a mitigation action for a single API Shield operation, superseding
69 * the zone-level default just for that operation.
70 *
71 * The override is keyed by the operation's UUID; deleting the resource
72 * clears the override so the operation falls back to the zone default.
73 * Deleting the underlying API Shield operation cascades the override away.
74 * ### Overriding an operation
75 * **Example:** Block non-conforming requests on one operation
76 * ```typescript
77 * const op = yield* Cloudflare.ApiShield.Operation("GetUser", {
78 * zoneId: zone.zoneId,
79 * method: "GET",
80 * host: "api.example.com",
81 * endpoint: "/users/{id}",
82 * });
83 *
84 * yield* Cloudflare.SchemaValidation.OperationSetting("BlockGetUser", {
85 * zoneId: zone.zoneId,
86 * operationId: op.operationId,
87 * mitigationAction: "block",
88 * });
89 * ```
90 *
91 * **Example:** Exempt an operation from validation
92 * ```typescript
93 * yield* Cloudflare.SchemaValidation.OperationSetting("SkipWebhook", {
94 * zoneId: zone.zoneId,
95 * operationId: webhookOp.operationId,
96 * mitigationAction: "none",
97 * });
98 * ```
99 *
100 * @see https://developers.cloudflare.com/api-shield/security/schema-validation/
101 *
102 * @resource
103 * @product Schema Validation
104 * @category Application Security
105 */
106export const OperationSetting = Resource<OperationSetting>(TypeId);
107
108/**
109 * Returns true if the given value is a OperationSetting
110 * resource.
111 */
112export const isOperationSetting = (value: unknown): value is OperationSetting =>
113 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
114
115export const OperationSettingProvider = () =>
116 Provider.succeed(OperationSetting, {
117 stables: ["zoneId", "operationId"],
118
119 list: Effect.fn(function* () {
120 const { accountId } = yield* yield* CloudflareEnvironment;
121 // Per-operation overrides live inside a zone with no account-wide
122 // enumeration API — fan out over every zone and list its operation
123 // settings, exhaustively paginated.
124 const zones = yield* listAllZones(accountId);
125 const rows = yield* Effect.forEach(
126 zones,
127 (zone) =>
128 schemaValidation.listSettingOperations
129 .pages({ zoneId: zone.id })
130 .pipe(
131 Stream.runCollect,
132 Effect.map((chunk) =>
133 Array.from(chunk).flatMap((page) =>
134 (page.result ?? [])
135 // An operation with no override reports a nullish action;
136 // skip it so the result mirrors what `read` returns.
137 .filter((op) => op.mitigationAction != null)
138 .map((op) =>
139 toAttributes(zone.id, {
140 operationId: op.operationId,
141 // Distilled widens the generated enum to an open union.
142 mitigationAction:
143 op.mitigationAction as OperationMitigationAction,
144 }),
145 ),
146 ),
147 ),
148 // A zone with no API Shield / schema-validation entitlement
149 // rejects the route — skip it, keep the rest. (Transient
150 // code-10000 "Authentication error" blips under concurrency are
151 // retried globally by the Cloudflare retry policy, so they never
152 // reach here as a real failure.)
153 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
154 ),
155 { concurrency: 10 },
156 );
157 return rows.flat();
158 }),
159
160 diff: Effect.fn(function* ({ news, output }) {
161 if (!isResolved(news)) return undefined;
162 // The (zoneId, operationId) pair is the override's identity.
163 if (
164 output?.zoneId !== undefined &&
165 typeof news.zoneId === "string" &&
166 news.zoneId !== output.zoneId
167 ) {
168 return { action: "replace" } as const;
169 }
170 if (
171 output?.operationId !== undefined &&
172 typeof news.operationId === "string" &&
173 news.operationId !== output.operationId
174 ) {
175 return { action: "replace" } as const;
176 }
177 return undefined;
178 }),
179
180 read: Effect.fn(function* ({ output, olds }) {
181 const zoneId =
182 output?.zoneId ??
183 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
184 const operationId =
185 output?.operationId ??
186 (typeof olds?.operationId === "string" ? olds.operationId : undefined);
187 if (zoneId === undefined || operationId === undefined) return undefined;
188
189 const observed = yield* getOperationSetting(zoneId, operationId);
190 if (observed === undefined) return undefined;
191 const attrs = toAttributes(zoneId, observed);
192 // An override carries no ownership marker. When we have no prior
193 // output (cold read), an existing override was set out-of-band —
194 // brand it `Unowned` so the engine gates takeover behind adoption.
195 return output !== undefined ? attrs : Unowned(attrs);
196 }),
197
198 reconcile: Effect.fn(function* ({ news }) {
199 // Inputs have been resolved to concrete strings by Plan.
200 const zoneId = news.zoneId as string;
201 const operationId = news.operationId as string;
202
203 // 1. Observe — an absent override (`OperationNotFound` on an
204 // operation with no override) simply means "not set yet".
205 const observed = yield* getOperationSetting(zoneId, operationId);
206
207 // 2/3. Ensure + Sync — the PUT is a true upsert, so a single call
208 // converges both the greenfield and the drifted case. Skip it
209 // entirely on a no-op.
210 if (observed?.mitigationAction === news.mitigationAction) {
211 return toAttributes(zoneId, observed);
212 }
213 const updated = yield* schemaValidation.putSettingOperation({
214 zoneId,
215 operationId,
216 mitigationAction: news.mitigationAction,
217 });
218 return toAttributes(zoneId, {
219 operationId: updated.operationId,
220 // Distilled widens the generated enum to an open union.
221 mitigationAction: updated.mitigationAction as OperationMitigationAction,
222 });
223 }),
224
225 delete: Effect.fn(function* ({ output }) {
226 // Idempotent — clearing an already-cleared override (or one that
227 // cascaded away with its operation) is success.
228 yield* schemaValidation
229 .deleteSettingOperation({
230 zoneId: output.zoneId,
231 operationId: output.operationId,
232 })
233 .pipe(Effect.catchTag("OperationNotFound", () => Effect.void));
234 }),
235 });
236
237/**
238 * Read an operation override, mapping both "gone" shapes to `undefined`:
239 * `OperationNotFound` (code 10404) when the operation itself does not
240 * exist, and a 200 response without `mitigation_action` when the operation
241 * exists but carries no override.
242 */
243const getOperationSetting = (zoneId: string, operationId: string) =>
244 schemaValidation.getSettingOperation({ zoneId, operationId }).pipe(
245 Effect.map((setting) =>
246 setting.mitigationAction == null
247 ? undefined
248 : {
249 operationId: setting.operationId,
250 // Distilled widens the generated enum to an open union.
251 mitigationAction:
252 setting.mitigationAction as OperationMitigationAction,
253 },
254 ),
255 Effect.catchTag("OperationNotFound", () => Effect.succeed(undefined)),
256 );
257
258const toAttributes = (
259 zoneId: string,
260 setting: {
261 operationId: string;
262 mitigationAction: OperationMitigationAction;
263 },
264): OperationSettingAttributes => ({
265 zoneId,
266 operationId: setting.operationId,
267 mitigationAction: setting.mitigationAction,
268});