1import * as apiGateway from "@distilled.cloud/cloudflare/api-gateway";
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 { createPhysicalName } from "../../PhysicalName.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.ApiShield.UserSchema" as const;
15type TypeId = typeof TypeId;
16
17export interface UserSchemaProps {
18 /**
19 * Zone the schema is uploaded to.
20 *
21 * Immutable — moving a schema between zones triggers a replacement.
22 */
23 zoneId: string;
24 /**
25 * Name of the schema. If omitted, a unique name is generated from the
26 * app, stage, and logical ID.
27 *
28 * Immutable — there is no rename API, so changing the name triggers a
29 * replacement.
30 * @default ${app}-${id}-${stage}-${suffix}
31 */
32 name?: string;
33 /**
34 * The OpenAPI v3 schema source (JSON or YAML) to upload.
35 *
36 * Immutable — Cloudflare offers no API to update an uploaded schema's
37 * contents, so changing the source triggers a replacement.
38 */
39 schema: string;
40 /**
41 * Whether the schema is enabled for (legacy "classic") schema
42 * validation. Enabling is an in-place update; Cloudflare forbids
43 * disabling an enabled schema (error 20400), so turning this back off
44 * triggers a replacement.
45 * @default false
46 */
47 validationEnabled?: boolean;
48}
49
50export interface UserSchemaAttributes {
51 /** Cloudflare-assigned UUID of the schema. */
52 schemaId: string;
53 /** Zone the schema is uploaded to. */
54 zoneId: string;
55 /** Name of the schema. */
56 name: string;
57 /** Kind of schema. Always `openapi_v3`. */
58 kind: "openapi_v3" | (string & {});
59 /** The schema source as stored by Cloudflare. */
60 source: string;
61 /** Whether the schema is enabled for validation. */
62 validationEnabled: boolean;
63 /** ISO8601 creation timestamp. */
64 createdAt: string;
65}
66
67export type UserSchema = Resource<
68 TypeId,
69 UserSchemaProps,
70 UserSchemaAttributes,
71 never,
72 Providers
73>;
74
75/**
76 * A Cloudflare API Shield user schema — an OpenAPI v3 document uploaded to
77 * a zone for (legacy "classic") schema validation of API traffic.
78 *
79 * An uploaded schema's contents cannot be modified, so changing the
80 * `schema` source (or the `name`) triggers a replacement. The only in-place
81 * update is enabling validation; Cloudflare forbids disabling an enabled
82 * schema, so turning validation back off also triggers a replacement.
83 *
84 * For current zone-level schema validation (v2), prefer the
85 * `Cloudflare.SchemaValidation` resources.
86 * ### Uploading a Schema
87 * **Example:** Upload an OpenAPI v3 schema
88 * ```typescript
89 * const fs = yield* FileSystem.FileSystem;
90 * const source = yield* fs.readFileString("./openapi.json");
91 *
92 * const schema = yield* Cloudflare.ApiShield.UserSchema("PetstoreSchema", {
93 * zoneId: zone.zoneId,
94 * name: "petstore",
95 * schema: source,
96 * });
97 * // schema.schemaId is the Cloudflare-assigned UUID
98 * ```
99 *
100 * **Example:** Upload and enable validation
101 * ```typescript
102 * yield* Cloudflare.ApiShield.UserSchema("PetstoreSchema", {
103 * zoneId: zone.zoneId,
104 * schema: source,
105 * validationEnabled: true,
106 * });
107 * ```
108 *
109 * @see https://developers.cloudflare.com/api-shield/security/schema-validation/
110 *
111 * @resource
112 * @product API Shield
113 * @category Application Security
114 */
115export const UserSchema = Resource<UserSchema>(TypeId);
116
117/**
118 * Returns true if the given value is an UserSchema resource.
119 */
120export const isUserSchema = (value: unknown): value is UserSchema =>
121 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
122
123export const UserSchemaProvider = () =>
124 Provider.succeed(UserSchema, {
125 stables: ["zoneId", "name", "kind"],
126
127 diff: Effect.fn(function* ({ id, olds, news, output }) {
128 const o = olds as UserSchemaProps | undefined;
129 const n = news as UserSchemaProps;
130 if (o === undefined) return undefined;
131 // The name is part of the schema's identity — compare the resolved
132 // physical names (an omitted name resolves deterministically).
133 const oldName = output?.name ?? (yield* createSchemaName(id, o.name));
134 // Auto-generated names are engine-owned: the deployed name stays
135 // authoritative even if the generator would name this id differently
136 // today. Only an explicit user-provided name can force a replace.
137 const newName = n.name ?? oldName;
138 if (oldName !== newName) {
139 return { action: "replace" } as const;
140 }
141 // The uploaded source cannot be modified in place.
142 if (o.schema !== n.schema) {
143 return { action: "replace" } as const;
144 }
145 // Cloudflare forbids disabling validation on an enabled schema.
146 if ((o.validationEnabled ?? false) && !(n.validationEnabled ?? false)) {
147 return { action: "replace" } as const;
148 }
149 // zoneId is Input<string>; compare only once both are concrete.
150 if (
151 typeof o.zoneId === "string" &&
152 typeof n.zoneId === "string" &&
153 o.zoneId !== n.zoneId
154 ) {
155 return { action: "replace" } as const;
156 }
157 return undefined;
158 }),
159
160 read: Effect.fn(function* ({ id, output, olds }) {
161 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
162 if (zoneId === undefined) return undefined;
163
164 // Owned path: refresh by the persisted schema id.
165 if (output?.schemaId) {
166 const observed = yield* getSchema(zoneId, output.schemaId);
167 return observed ? toAttributes(observed, zoneId) : undefined;
168 }
169
170 // Cold path: recover from lost state via the deterministic name.
171 // Schemas carry no ownership markers, so an existing schema is
172 // reported as `Unowned` and adoption is gated by the adopt policy.
173 const name = yield* createSchemaName(id, olds?.name);
174 const observed = yield* findByName(zoneId, name);
175 return observed ? Unowned(toAttributes(observed, zoneId)) : undefined;
176 }),
177
178 reconcile: Effect.fn(function* ({ id, news, output }) {
179 // Inputs have been resolved to concrete strings by Plan.
180 const zoneId = news.zoneId as string;
181 const name = output?.name ?? (yield* createSchemaName(id, news.name));
182 const desiredEnabled = news.validationEnabled ?? false;
183
184 // 1. Observe — the schema id cached on `output` is a hint, not a
185 // guarantee: a missing schema falls through to ensure.
186 let observed = output?.schemaId
187 ? yield* getSchema(zoneId, output.schemaId)
188 : undefined;
189
190 // An adopted (or drifted) schema whose stored source no longer
191 // matches the desired one cannot be updated in place — converge by
192 // recreating it.
193 if (observed && (observed.source ?? "") !== news.schema) {
194 yield* apiGateway
195 .deleteUserSchema({ zoneId, schemaId: observed.schemaId })
196 .pipe(Effect.catchTag("SchemaNotFound", () => Effect.void));
197 observed = undefined;
198 }
199
200 // 2. Ensure — upload when missing. Names are not unique, so there is
201 // no AlreadyExists race to tolerate.
202 if (!observed) {
203 const file = yield* Effect.sync(
204 () => new File([news.schema], `${name}.json`),
205 );
206 const created = yield* apiGateway.createUserSchema({
207 zoneId,
208 file,
209 kind: "openapi_v3",
210 name,
211 validationEnabled: desiredEnabled,
212 });
213 observed = created.schema;
214 }
215
216 // 3. Sync — enabling validation is the only in-place update (the
217 // disable direction is a replacement, handled by diff). Skip the
218 // patch entirely on a no-op.
219 if (desiredEnabled && !(observed.validationEnabled ?? false)) {
220 const patched = yield* apiGateway.patchUserSchema({
221 zoneId,
222 schemaId: observed.schemaId,
223 validationEnabled: true,
224 });
225 // The patch response omits the source — keep the observed one.
226 observed = { ...patched, source: observed.source };
227 }
228
229 return toAttributes(observed, zoneId);
230 }),
231
232 delete: Effect.fn(function* ({ output }) {
233 yield* apiGateway
234 .deleteUserSchema({
235 zoneId: output.zoneId,
236 schemaId: output.schemaId,
237 })
238 .pipe(Effect.catchTag("SchemaNotFound", () => Effect.void));
239 }),
240
241 // Schemas live inside a zone; there is no account-wide list. Fan out
242 // over every zone in the account and exhaustively paginate each zone's
243 // schemas, hydrating into the same `Attributes` shape `read` produces.
244 list: Effect.fn(function* () {
245 const { accountId } = yield* yield* CloudflareEnvironment;
246 const zones = yield* listAllZones(accountId);
247 const rows = yield* Effect.forEach(
248 zones,
249 (zone) =>
250 apiGateway.listUserSchemas.pages({ zoneId: zone.id }).pipe(
251 Stream.runCollect,
252 Effect.map((chunk) =>
253 Array.from(chunk).flatMap((page) =>
254 (page.result ?? []).map((schema) =>
255 toAttributes(schema, zone.id),
256 ),
257 ),
258 ),
259 // Zones without the API Shield entitlement reject the listing
260 // route; skip them rather than failing the whole enumeration.
261 Effect.catchTag("Forbidden", () =>
262 Effect.succeed([] as UserSchemaAttributes[]),
263 ),
264 // A zone purged (deleted) out-of-band mid-enumeration — it was in
265 // the zone list but no longer exists; drop it.
266 Effect.catchTag("ZonePurged", () =>
267 Effect.succeed([] as UserSchemaAttributes[]),
268 ),
269 ),
270 { concurrency: 10 },
271 );
272 return rows.flat();
273 }),
274 });
275
276type ObservedSchema = Pick<
277 apiGateway.GetUserSchemaResponse,
278 "schemaId" | "name" | "kind" | "source" | "validationEnabled" | "createdAt"
279>;
280
281/**
282 * Read a schema by id, mapping "gone" (`SchemaNotFound`, Cloudflare error
283 * code 19400) to `undefined`.
284 */
285const getSchema = (zoneId: string, schemaId: string) =>
286 apiGateway.getUserSchema({ zoneId, schemaId }).pipe(
287 Effect.map((schema): ObservedSchema | undefined => schema),
288 Effect.catchTag("SchemaNotFound", () => Effect.succeed(undefined)),
289 );
290
291/**
292 * Find a schema by name. Cloudflare does not enforce name uniqueness, so
293 * the first match wins — cold recovery only ever races with itself here
294 * because generated names are deterministic per logical ID.
295 */
296const findByName = (zoneId: string, name: string) =>
297 apiGateway.listUserSchemas.items({ zoneId }).pipe(
298 Stream.runCollect,
299 Effect.map((chunk) =>
300 Array.from(chunk).find(
301 (schema): schema is ObservedSchema & typeof schema =>
302 schema.name === name,
303 ),
304 ),
305 );
306
307/**
308 * Resolve the schema's physical name: the explicit prop wins; otherwise a
309 * deterministic name is generated from the app, stage, and logical ID.
310 */
311const createSchemaName = (id: string, name: string | undefined) =>
312 Effect.gen(function* () {
313 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
314 });
315
316const toAttributes = (
317 schema: ObservedSchema,
318 zoneId: string,
319): UserSchemaAttributes => ({
320 schemaId: schema.schemaId,
321 zoneId,
322 name: schema.name,
323 kind: schema.kind,
324 source: schema.source ?? "",
325 validationEnabled: schema.validationEnabled ?? false,
326 createdAt: schema.createdAt,
327});