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 { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15const TypeId = "Cloudflare.SchemaValidation.Schema" as const;
16type TypeId = typeof TypeId;
17
18export interface SchemaProps {
19 /**
20 * Zone the schema is uploaded to.
21 *
22 * Immutable — moving a schema between zones triggers a replacement.
23 */
24 zoneId: string;
25 /**
26 * Human-readable name for the schema. Cloudflare does not enforce
27 * uniqueness, but Alchemy uses the name as the cold-read identity, so it
28 * should be unique within the zone. If omitted, a unique name is
29 * generated from the app, stage, and logical ID.
30 *
31 * Immutable — there is no rename API, so changing the name triggers a
32 * replacement.
33 * @default ${app}-${stage}-${id}
34 */
35 name?: string;
36 /**
37 * The kind of the schema. Only OpenAPI v3 is supported.
38 *
39 * Immutable — changing the kind triggers a replacement.
40 * @default "openapi_v3"
41 */
42 kind?: "openapi_v3";
43 /**
44 * The raw OpenAPI v3 schema, as a JSON or YAML string. Cloudflare
45 * validates the document on upload and rejects invalid specs.
46 *
47 * Immutable — the uploaded source cannot be modified, so changing it
48 * triggers a replacement (a new schema is uploaded, then the old one is
49 * deleted).
50 */
51 source: string;
52 /**
53 * Whether the schema is enabled for validation.
54 *
55 * Enabling a disabled schema is an in-place update, but Cloudflare does
56 * not allow disabling an enabled schema ("delete schema instead"), so
57 * changing `true` → `false` triggers a replacement.
58 * @default true
59 */
60 validationEnabled?: boolean;
61}
62
63export interface SchemaAttributes {
64 /** Cloudflare-assigned UUID of the schema. */
65 schemaId: string;
66 /** Zone the schema is uploaded to. */
67 zoneId: string;
68 /** Human-readable name of the schema. */
69 name: string;
70 /** The kind of the schema. */
71 kind: "openapi_v3" | (string & {});
72 /** The raw schema source as stored by Cloudflare. */
73 source: string;
74 /** Whether the schema is enabled for validation. */
75 validationEnabled: boolean;
76 /** When the schema was uploaded. */
77 createdAt: string;
78}
79
80export type SchemaValidationSchema = Resource<
81 TypeId,
82 SchemaProps,
83 SchemaAttributes,
84 never,
85 Providers
86>;
87
88/**
89 * An OpenAPI v3 schema uploaded to a zone for API Shield schema validation
90 * (`/zones/{zone_id}/schema_validation/schemas`).
91 *
92 * Uploading a schema registers the endpoints it describes as API Shield
93 * operations (a server-side side effect — deleting the schema does not
94 * delete those operations). The schema body is immutable: changing `source`
95 * uploads a new schema and deletes the old one (replacement). Only the
96 * `validationEnabled` flag is mutable in place.
97 * ### Uploading a Schema
98 * **Example:** Upload an OpenAPI v3 schema
99 * ```typescript
100 * const schema = yield* Cloudflare.SchemaValidation.SchemaValidationSchema("ApiSchema", {
101 * zoneId: zone.zoneId,
102 * source: JSON.stringify({
103 * openapi: "3.0.0",
104 * info: { title: "my-api", version: "1.0.0" },
105 * servers: [{ url: "https://api.example.com" }],
106 * paths: {
107 * "/users": {
108 * get: {
109 * operationId: "listUsers",
110 * responses: { "200": { description: "ok" } },
111 * },
112 * },
113 * },
114 * }),
115 * });
116 * ```
117 *
118 * **Example:** Upload a schema without enabling validation
119 * ```typescript
120 * const schema = yield* Cloudflare.SchemaValidation.SchemaValidationSchema("DraftSchema", {
121 * zoneId: zone.zoneId,
122 * source: openApiDocument,
123 * validationEnabled: false,
124 * });
125 * ```
126 *
127 * ### Toggling validation
128 * **Example:** Enable a previously-disabled schema in place
129 * ```typescript
130 * // Enabling (false → true) patches the schema in place. Disabling an
131 * // enabled schema is rejected by Cloudflare, so `true` → `false` (like a
132 * // `source` change) replaces the schema instead.
133 * yield* Cloudflare.SchemaValidation.SchemaValidationSchema("DraftSchema", {
134 * zoneId: zone.zoneId,
135 * source: openApiDocument,
136 * validationEnabled: true,
137 * });
138 * ```
139 *
140 * @see https://developers.cloudflare.com/api-shield/security/schema-validation/
141 *
142 * @resource
143 * @product Schema Validation
144 * @category Application Security
145 */
146export const SchemaValidationSchema = Resource<SchemaValidationSchema>(TypeId);
147
148/**
149 * Returns true if the given value is a SchemaValidationSchema resource.
150 */
151export const isSchema = (value: unknown): value is SchemaValidationSchema =>
152 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
153
154export const SchemaProvider = () =>
155 Provider.succeed(SchemaValidationSchema, {
156 stables: ["schemaId", "zoneId", "name", "kind", "source", "createdAt"],
157
158 diff: Effect.fn(function* ({ olds, news, output }) {
159 if (!isResolved(news)) return undefined;
160 // zoneId is a path param — moving zones is a replacement.
161 if (
162 output?.zoneId !== undefined &&
163 typeof news.zoneId === "string" &&
164 news.zoneId !== output.zoneId
165 ) {
166 return { action: "replace" } as const;
167 }
168 // kind is immutable.
169 const oldKind = output?.kind ?? olds?.kind ?? "openapi_v3";
170 if ((news.kind ?? "openapi_v3") !== oldKind) {
171 return { action: "replace" } as const;
172 }
173 // There is no rename API. A generated name (news.name undefined) is
174 // deterministic and therefore never drifts.
175 const oldName = output?.name ?? olds?.name;
176 if (
177 news.name !== undefined &&
178 oldName !== undefined &&
179 news.name !== oldName
180 ) {
181 return { action: "replace" } as const;
182 }
183 // The uploaded source is immutable. Prefer the previously-passed
184 // props as the baseline (what the user last declared) over the
185 // stored copy, in case Cloudflare ever normalizes the document.
186 const oldSource = olds?.source ?? output?.source;
187 if (oldSource !== undefined && news.source !== oldSource) {
188 return { action: "replace" } as const;
189 }
190 // Cloudflare rejects disabling an enabled schema in place
191 // ("Disabling a schema is not allowed, delete schema instead.") —
192 // converge by replacing: upload a new, disabled copy and delete the
193 // old one. Enabling (false → true) is a plain in-place update.
194 const oldEnabled =
195 output?.validationEnabled ?? olds?.validationEnabled ?? true;
196 if (oldEnabled && news.validationEnabled === false) {
197 return { action: "replace" } as const;
198 }
199 return undefined;
200 }),
201
202 read: Effect.fn(function* ({ id, output, olds }) {
203 const zoneId =
204 output?.zoneId ??
205 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
206 if (zoneId === undefined) return undefined;
207
208 if (output?.schemaId) {
209 const observed = yield* getSchema(zoneId, output.schemaId);
210 return observed ? toAttributes(zoneId, observed) : undefined;
211 }
212
213 // Cold read — recover from lost state by matching the deterministic
214 // name. Names are not unique server-side and carry no ownership
215 // marker, so brand the match `Unowned` and let the engine gate
216 // takeover behind adoption.
217 const name = yield* createSchemaName(id, olds?.name);
218 const match = yield* findByName(zoneId, name);
219 if (match === undefined) return undefined;
220 const observed = yield* getSchema(zoneId, match.schemaId);
221 return observed ? Unowned(toAttributes(zoneId, observed)) : undefined;
222 }),
223
224 reconcile: Effect.fn(function* ({ id, news, output }) {
225 // Inputs have been resolved to concrete strings by Plan.
226 const zoneId = news.zoneId as string;
227 const name = yield* createSchemaName(id, news.name);
228 const desiredEnabled = news.validationEnabled ?? true;
229
230 // 1. Observe — the cached schemaId is a hint, not a guarantee: a
231 // SchemaNotFound falls through to "missing" and we re-upload.
232 const observed = output?.schemaId
233 ? yield* getSchema(zoneId, output.schemaId)
234 : undefined;
235
236 // 2. Ensure — upload when missing. Names are not unique server-side,
237 // so there is no AlreadyExists race to tolerate.
238 if (observed === undefined) {
239 const created = yield* schemaValidation.createSchema({
240 zoneId,
241 kind: news.kind ?? "openapi_v3",
242 name,
243 source: news.source,
244 validationEnabled: desiredEnabled,
245 });
246 return toAttributes(zoneId, created);
247 }
248
249 // 3. Sync — `validationEnabled` is the only mutable aspect; the
250 // source/name/kind are immutable and disabling is rejected by the
251 // API, so diff replaces on those changes. Skip the API call
252 // entirely on a no-op.
253 if ((observed.validationEnabled ?? false) === desiredEnabled) {
254 return toAttributes(zoneId, observed);
255 }
256 const patched = yield* schemaValidation.patchSchema({
257 zoneId,
258 schemaId: observed.schemaId,
259 validationEnabled: desiredEnabled,
260 });
261 // The PATCH response omits the stored source (returns "") — keep the
262 // observed copy so the attributes stay faithful.
263 return toAttributes(zoneId, { ...patched, source: observed.source });
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 // Idempotent — a schema already deleted out-of-band is success.
268 yield* schemaValidation
269 .deleteSchema({ zoneId: output.zoneId, schemaId: output.schemaId })
270 .pipe(Effect.catchTag("SchemaNotFound", () => Effect.void));
271 }),
272
273 // Zone-scoped collection: schemas live under `/zones/{zone_id}/...`, so
274 // enumerate every zone in the account and list its schemas, paginating
275 // each exhaustively. `omitSource: false` hydrates the full `read`
276 // Attributes shape. Zones whose route is invalid (deleted/partial) reject
277 // with the typed `InvalidRoute` — skip them rather than failing the whole
278 // enumeration.
279 list: Effect.fn(function* () {
280 const { accountId } = yield* yield* CloudflareEnvironment;
281 const zones = yield* listAllZones(accountId);
282 const rows = yield* Effect.forEach(
283 zones,
284 (zone) =>
285 schemaValidation.listSchemas
286 .pages({ zoneId: zone.id, omitSource: false })
287 .pipe(
288 Stream.runCollect,
289 Effect.map((chunk) =>
290 Array.from(chunk).flatMap((page) =>
291 (page.result ?? []).map((schema) =>
292 toAttributes(zone.id, schema),
293 ),
294 ),
295 ),
296 // Skip zones schema-validation can't enumerate: `InvalidRoute`
297 // (feature not available on the zone), `ZonePurged` (the
298 // account-wide zone listing can momentarily include a zone that
299 // has since been purged), and `Forbidden` (the scoped token /
300 // zone plan doesn't grant schema-validation access).
301 Effect.catchTag(["InvalidRoute", "ZonePurged", "Forbidden"], () =>
302 Effect.succeed<SchemaAttributes[]>([]),
303 ),
304 ),
305 { concurrency: 10 },
306 );
307 return rows.flat();
308 }),
309 });
310
311/**
312 * Read a schema by id, mapping "gone" (`SchemaNotFound`, Cloudflare error
313 * code 19400) to `undefined`.
314 */
315const getSchema = (zoneId: string, schemaId: string) =>
316 schemaValidation
317 .getSchema({ zoneId, schemaId, omitSource: false })
318 .pipe(Effect.catchTag("SchemaNotFound", () => Effect.succeed(undefined)));
319
320/**
321 * Find a schema by exact name (meta-data only — sources omitted). If several
322 * schemas carry the same name, pick the oldest for determinism.
323 */
324const findByName = (zoneId: string, name: string) =>
325 schemaValidation.listSchemas.items({ zoneId, omitSource: true }).pipe(
326 Stream.filter((s) => s.name === name),
327 Stream.runCollect,
328 Effect.map((chunk) =>
329 Array.from(chunk)
330 .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
331 .at(0),
332 ),
333 );
334
335const createSchemaName = (id: string, name: string | undefined) =>
336 Effect.gen(function* () {
337 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
338 });
339
340const toAttributes = (
341 zoneId: string,
342 schema:
343 | schemaValidation.GetSchemaResponse
344 | schemaValidation.CreateSchemaResponse
345 | schemaValidation.PatchSchemaResponse
346 | schemaValidation.ListSchemasResponse["result"][number],
347): SchemaAttributes => ({
348 schemaId: schema.schemaId,
349 zoneId,
350 name: schema.name,
351 kind: schema.kind,
352 source: schema.source,
353 validationEnabled: schema.validationEnabled ?? false,
354 createdAt: schema.createdAt,
355});