@nanobpm/urban 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/core/modules/api.d.ts +61 -0
  4. package/dist/runtime/core/modules/api.js +359 -0
  5. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  6. package/dist/runtime/core/modules/cancel.js +90 -0
  7. package/dist/runtime/core/modules/datasource.d.ts +5 -2
  8. package/dist/runtime/core/modules/datasource.js +5 -4
  9. package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
  10. package/dist/runtime/core/modules/instance-tracking.js +28 -11
  11. package/dist/runtime/core/modules/pages.d.ts +8 -0
  12. package/dist/runtime/core/modules/pages.js +17 -1
  13. package/dist/runtime/core/modules/surfaces.js +9 -0
  14. package/dist/runtime/engine/nanosdk.js +41 -7
  15. package/dist/runtime/index.d.ts +4 -0
  16. package/dist/runtime/index.js +2 -0
  17. package/dist/toolkit/artifact.d.ts +5 -0
  18. package/dist/toolkit/artifact.js +7 -0
  19. package/dist/toolkit/derivers/api.d.ts +19 -0
  20. package/dist/toolkit/derivers/api.js +224 -0
  21. package/dist/toolkit/gen.js +23 -4
  22. package/dist/toolkit/index.d.ts +5 -0
  23. package/dist/toolkit/index.js +12 -1
  24. package/package.json +1 -1
  25. package/src/openapi/spec.test.ts +292 -0
  26. package/src/openapi/spec.ts +540 -0
  27. package/src/runtime/core/modules/api.test.ts +445 -0
  28. package/src/runtime/core/modules/api.ts +453 -0
  29. package/src/runtime/core/modules/cancel.test.ts +310 -0
  30. package/src/runtime/core/modules/cancel.ts +119 -0
  31. package/src/runtime/core/modules/datasource.ts +5 -4
  32. package/src/runtime/core/modules/instance-tracking.ts +33 -11
  33. package/src/runtime/core/modules/pages.test.ts +46 -2
  34. package/src/runtime/core/modules/pages.ts +23 -1
  35. package/src/runtime/core/modules/surfaces.ts +10 -0
  36. package/src/runtime/engine/nanosdk.test.ts +45 -5
  37. package/src/runtime/engine/nanosdk.ts +39 -7
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/artifact.ts +8 -0
  40. package/src/toolkit/derivers/api.test.ts +219 -0
  41. package/src/toolkit/derivers/api.ts +255 -0
  42. package/src/toolkit/gen.test.ts +39 -0
  43. package/src/toolkit/gen.ts +24 -4
  44. package/src/toolkit/index.ts +41 -1
@@ -0,0 +1,540 @@
1
+ // Shared, pure OpenAPI machinery for the Urban endpoint surface (ADR 0058). Both the toolkit
2
+ // deriver (`toolkit/derivers/api.ts`, authoring-time type + wrapper emission) and the runtime
3
+ // (`runtime/core/modules/api.ts`, request validation + routing) build on this one module, so the
4
+ // derived types and the runtime routes/validation always agree. No IO, no `node:*`, no `Deno` —
5
+ // it operates on an already-parsed document object.
6
+ //
7
+ // Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
8
+ // requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
9
+ // string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
10
+ // XML, discriminated oneOf composition) is intentionally out of scope for this slice.
11
+
12
+ /** A JSON Schema (the OpenAPI subset we read). Kept structural and permissive — unknown keywords
13
+ * are ignored by the validator and fall back to `unknown` in the type emitter. */
14
+ export interface OpenApiSchema {
15
+ $ref?: string;
16
+ type?: string;
17
+ nullable?: boolean;
18
+ enum?: unknown[];
19
+ const?: unknown;
20
+ format?: string;
21
+ // object
22
+ properties?: Record<string, OpenApiSchema>;
23
+ required?: string[];
24
+ additionalProperties?: boolean | OpenApiSchema;
25
+ // array
26
+ items?: OpenApiSchema;
27
+ minItems?: number;
28
+ maxItems?: number;
29
+ // string
30
+ minLength?: number;
31
+ maxLength?: number;
32
+ pattern?: string;
33
+ // number
34
+ minimum?: number;
35
+ maximum?: number;
36
+ exclusiveMinimum?: number | boolean;
37
+ exclusiveMaximum?: number | boolean;
38
+ // composition (best-effort)
39
+ allOf?: OpenApiSchema[];
40
+ oneOf?: OpenApiSchema[];
41
+ anyOf?: OpenApiSchema[];
42
+ description?: string;
43
+ }
44
+
45
+ /**
46
+ * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
47
+ * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
48
+ * an object. The type emitter (`schemaToTs`) and the runtime validator (`validateValue`)
49
+ * both route object detection through this single predicate so they never drift — a body
50
+ * the emitted type calls an object is the same body the validator shape-checks.
51
+ */
52
+ export function isObjectSchema(schema: OpenApiSchema): boolean {
53
+ if (schema.type === "object") return true;
54
+ if (schema.type !== undefined) return false;
55
+ return (
56
+ schema.properties !== undefined ||
57
+ schema.required !== undefined ||
58
+ schema.additionalProperties !== undefined
59
+ );
60
+ }
61
+
62
+ export interface OpenApiParameter {
63
+ name: string;
64
+ in: "path" | "query" | "header" | "cookie";
65
+ required?: boolean;
66
+ schema?: OpenApiSchema;
67
+ }
68
+
69
+ export interface OpenApiMediaType {
70
+ schema?: OpenApiSchema;
71
+ }
72
+
73
+ export interface OpenApiRequestBody {
74
+ required?: boolean;
75
+ content?: Record<string, OpenApiMediaType>;
76
+ }
77
+
78
+ export interface OpenApiResponse {
79
+ description?: string;
80
+ content?: Record<string, OpenApiMediaType>;
81
+ }
82
+
83
+ export interface OpenApiDoc {
84
+ openapi?: string;
85
+ info?: { title?: string; version?: string };
86
+ paths?: Record<string, Record<string, unknown>>;
87
+ components?: { schemas?: Record<string, OpenApiSchema> };
88
+ }
89
+
90
+ /** The HTTP methods we mount, lowercase as they appear as path-item keys. */
91
+ export const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"] as const;
92
+ export type HttpMethodLower = (typeof HTTP_METHODS)[number];
93
+
94
+ /** One resolved operation: its id, method, path, parameters, request body schema, and responses.
95
+ * This is the shared unit both the deriver (→ types) and the runtime (→ routes) consume. */
96
+ export interface OperationInfo {
97
+ operationId: string;
98
+ method: HttpMethodLower;
99
+ /** The OpenAPI path template, e.g. "/invoices/{id}". */
100
+ path: string;
101
+ parameters: OpenApiParameter[];
102
+ requestBodySchema?: OpenApiSchema;
103
+ requestBodyRequired: boolean;
104
+ /** Schema of the first 2xx (else default) JSON response, when declared. */
105
+ responseSchema?: OpenApiSchema;
106
+ eject: boolean;
107
+ summary?: string;
108
+ }
109
+
110
+ function isRecord(v: unknown): v is Record<string, unknown> {
111
+ return typeof v === "object" && v !== null && !Array.isArray(v);
112
+ }
113
+
114
+ /** A structural gate for a schema object: any record is treated as a (loose) OpenAPI schema. */
115
+ function isSchema(v: unknown): v is OpenApiSchema {
116
+ return isRecord(v);
117
+ }
118
+
119
+ /** Parse an OpenAPI document from text. JSON only in this slice (ADR 0058 open question: YAML).
120
+ * Throws with a clear message on malformed input. */
121
+ export function parseSpec(text: string): OpenApiDoc {
122
+ let doc: unknown;
123
+ try {
124
+ doc = JSON.parse(text);
125
+ } catch (e) {
126
+ throw new Error(
127
+ `OpenAPI spec is not valid JSON (YAML is not yet supported — ADR 0058): ${
128
+ e instanceof Error ? e.message : String(e)
129
+ }`,
130
+ );
131
+ }
132
+ if (!isRecord(doc)) throw new Error("OpenAPI spec must be a JSON object");
133
+ return doc;
134
+ }
135
+
136
+ /** The basename of a local `#/components/schemas/Name` ref, else undefined. */
137
+ export function refName(ref: string): string | undefined {
138
+ const m = ref.match(/^#\/components\/schemas\/([^/]+)$/);
139
+ return m ? m[1] : undefined;
140
+ }
141
+
142
+ /** Follow local component `$ref`s to the concrete schema (guarding cycles). Non-local refs → undefined. */
143
+ export function resolveSchema(
144
+ doc: OpenApiDoc,
145
+ schema: OpenApiSchema | undefined,
146
+ ): OpenApiSchema | undefined {
147
+ if (!schema) return undefined;
148
+ let current = schema;
149
+ const seen = new Set<string>();
150
+ while (current.$ref) {
151
+ if (seen.has(current.$ref)) return undefined; // cyclic ref guard
152
+ seen.add(current.$ref);
153
+ const name = refName(current.$ref);
154
+ const next = name ? doc.components?.schemas?.[name] : undefined;
155
+ if (!next) return undefined;
156
+ current = next;
157
+ }
158
+ return current;
159
+ }
160
+
161
+ function firstJsonSchema(
162
+ content: Record<string, unknown> | undefined,
163
+ ): OpenApiSchema | undefined {
164
+ if (!content) return undefined;
165
+ const pick = (media: unknown): OpenApiSchema | undefined =>
166
+ isRecord(media) && isSchema(media.schema) ? media.schema : undefined;
167
+ // JSON-only: the runtime always JSON.parses the body and responds application/json, so derive
168
+ // types/validation only from JSON media types (application/json, its charset variants, and any
169
+ // `+json` structured suffix). A non-JSON media type is ignored rather than typed as if JSON.
170
+ for (const [type, media] of Object.entries(content)) {
171
+ const base = type.split(";", 1)[0].trim().toLowerCase();
172
+ if (base === "application/json" || base.endsWith("+json")) {
173
+ const s = pick(media);
174
+ if (s) return s;
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ /** Walk the document's paths and return every operation that carries an operationId, in a stable
181
+ * (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
182
+ * them at `warn` and skips them too — this keeps the pure collector total. */
183
+ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
184
+ const out: OperationInfo[] = [];
185
+ const paths = doc.paths ?? {};
186
+ for (const path of Object.keys(paths).sort()) {
187
+ const item = paths[path];
188
+ if (!isRecord(item)) continue;
189
+ // Path-level parameters apply to every operation on the path (OpenAPI Path Item Object).
190
+ const pathParams = Array.isArray(item.parameters) ? item.parameters : [];
191
+ for (const method of HTTP_METHODS) {
192
+ const opRaw = item[method];
193
+ if (!isRecord(opRaw)) continue;
194
+ const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
195
+ if (!operationId || !isSafeOperationId(operationId)) continue;
196
+ const opParams = Array.isArray(opRaw.parameters) ? opRaw.parameters : [];
197
+ const parameters = normalizeParameters([...pathParams, ...opParams]);
198
+ const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
199
+ const requestBodySchema = firstJsonSchema(
200
+ isRecord(requestBody?.content) ? requestBody.content : undefined,
201
+ );
202
+ const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
203
+ out.push({
204
+ operationId,
205
+ method,
206
+ path,
207
+ parameters,
208
+ requestBodySchema,
209
+ requestBodyRequired: requestBody?.required === true,
210
+ responseSchema: firstSuccessSchema(responses),
211
+ eject: opRaw["x-urban-eject"] === true,
212
+ summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
213
+ });
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** "method path" pointers for every operation that declares no `operationId`. Such operations are
220
+ * unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
221
+ export function operationsWithoutId(doc: OpenApiDoc): string[] {
222
+ const missing: string[] = [];
223
+ const paths = doc.paths ?? {};
224
+ for (const path of Object.keys(paths).sort()) {
225
+ const item = paths[path];
226
+ if (!isRecord(item)) continue;
227
+ for (const method of HTTP_METHODS) {
228
+ const opRaw = item[method];
229
+ if (!isRecord(opRaw)) continue;
230
+ if (typeof opRaw.operationId !== "string" || opRaw.operationId.length === 0) {
231
+ missing.push(`${method.toUpperCase()} ${path}`);
232
+ }
233
+ }
234
+ }
235
+ return missing;
236
+ }
237
+
238
+ /** "METHOD path {param}" pointers for every path-template parameter that no declared `in: "path"`
239
+ * parameter covers. Such params are still captured and passed to the delegate at runtime, but are
240
+ * neither typed (the deriver only emits declared params) nor validated — a type/runtime drift the
241
+ * runtime mount surfaces at `warn`. */
242
+ export function undeclaredPathParams(doc: OpenApiDoc): string[] {
243
+ const out: string[] = [];
244
+ for (const op of collectOperations(doc)) {
245
+ const declared = new Set(op.parameters.filter((p) => p.in === "path").map((p) => p.name));
246
+ for (const m of op.path.matchAll(/\{([^}]+)\}/g)) {
247
+ if (!declared.has(m[1])) out.push(`${op.method.toUpperCase()} ${op.path} {${m[1]}}`);
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /** An operationId names the delegate module file (`<dir>/<operationId>`), so it must be a single
254
+ * safe path segment. Rejects separators and parent-dir traversal so a crafted spec can't import a
255
+ * file outside the operations directory. Single source of truth for the rule (used by the pure
256
+ * collector, the diagnostic collector, and the runtime import sink). */
257
+ export function isSafeOperationId(id: string): boolean {
258
+ return id.length > 0 && !id.includes("/") && !id.includes("\\") && !id.includes("..") && id !== ".";
259
+ }
260
+
261
+ /** "METHOD path (operationId)" pointers for operations whose operationId is present but not a safe
262
+ * path segment. collectOperations skips these (they never mount or drive an import); the runtime
263
+ * surfaces them at `warn`. */
264
+ export function operationsWithUnsafeId(doc: OpenApiDoc): string[] {
265
+ const out: string[] = [];
266
+ const paths = doc.paths ?? {};
267
+ for (const path of Object.keys(paths).sort()) {
268
+ const item = paths[path];
269
+ if (!isRecord(item)) continue;
270
+ for (const method of HTTP_METHODS) {
271
+ const opRaw = item[method];
272
+ if (!isRecord(opRaw)) continue;
273
+ const id = opRaw.operationId;
274
+ if (typeof id === "string" && id.length > 0 && !isSafeOperationId(id)) {
275
+ out.push(`${method.toUpperCase()} ${path} (${id})`);
276
+ }
277
+ }
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function normalizeParameters(raw: unknown[]): OpenApiParameter[] {
283
+ // OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
284
+ // overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
285
+ // [...pathParams, ...opParams], so dedup later-wins (op overrides path) while preserving the
286
+ // first-seen position, so the emitted binding never carries two `path`/`query` fields with the
287
+ // same name (which would collide in the generated params/query types and in runtime extraction).
288
+ const byKey = new Map<string, OpenApiParameter>();
289
+ const order: string[] = [];
290
+ for (const p of raw) {
291
+ if (!isRecord(p)) continue;
292
+ const name = typeof p.name === "string" ? p.name : undefined;
293
+ const location = typeof p.in === "string" ? p.in : undefined;
294
+ if (!name || !location) continue;
295
+ if (location !== "path" && location !== "query" && location !== "header" && location !== "cookie") {
296
+ continue;
297
+ }
298
+ const schema = isRecord(p.schema) ? p.schema : undefined;
299
+ const key = `${location}\u0000${name}`;
300
+ if (!byKey.has(key)) order.push(key);
301
+ byKey.set(key, { name, in: location, required: p.required === true, schema });
302
+ }
303
+ return order.map((k) => byKey.get(k)!);
304
+ }
305
+
306
+ function firstSuccessSchema(responses: Record<string, unknown>): OpenApiSchema | undefined {
307
+ const codes = Object.keys(responses)
308
+ .filter((c) => /^2\d\d$/.test(c))
309
+ .sort();
310
+ const code = codes[0] ?? (isRecord(responses.default) ? "default" : undefined);
311
+ if (!code) return undefined;
312
+ const resp = responses[code];
313
+ if (!isRecord(resp)) return undefined;
314
+ return firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
315
+ }
316
+
317
+ /** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
318
+ * a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
319
+ * runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
320
+ /** Escape RegExp metacharacters so a string is matched literally inside a `new RegExp(...)`. */
321
+ export function escapeRegex(s: string): string {
322
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
323
+ }
324
+
325
+ export function toRouteMatcher(
326
+ base: string,
327
+ path: string,
328
+ ): { pattern: RegExp; paramNames: string[]; sample: string } {
329
+ const cleanBase = base.replace(/\/+$/, "");
330
+ const paramNames: string[] = [];
331
+ const segments = path.split("/").filter((s) => s.length > 0);
332
+ const regexParts = segments.map((seg) => {
333
+ const m = seg.match(/^\{([^}]+)\}$/);
334
+ if (m) {
335
+ paramNames.push(m[1]);
336
+ return "([^/]+)";
337
+ }
338
+ return escapeRegex(seg);
339
+ });
340
+ const body = regexParts.length > 0 ? `/${regexParts.join("/")}` : "";
341
+ // `base` is a literal path prefix (e.g. "/app/api(v2)"), not a pattern — escape its metacharacters
342
+ // just like the static template segments so it can't alter or break the matcher.
343
+ const pattern = new RegExp(`^${escapeRegex(cleanBase)}${body}/?$`);
344
+ const sample = `${cleanBase}${segments.length > 0 ? `/${segments.join("/")}` : ""}`;
345
+ return { pattern, paramNames, sample };
346
+ }
347
+
348
+ // ── Validation ──────────────────────────────────────────────────────────────────────────────
349
+
350
+ /** One validation failure: a JSON-pointer-ish path and a human message. */
351
+ export interface ValidationIssue {
352
+ path: string;
353
+ message: string;
354
+ }
355
+
356
+ /** Structural (deep) equality for JSON values — enum/const members can be objects or arrays, which
357
+ * a parsed request will never be `===` to. Compares by shape so those comparisons are correct. */
358
+ function deepEqual(a: unknown, b: unknown): boolean {
359
+ if (a === b) return true;
360
+ if (typeof a !== typeof b || a === null || b === null) return false;
361
+ if (Array.isArray(a) || Array.isArray(b)) {
362
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
363
+ return a.every((x, i) => deepEqual(x, b[i]));
364
+ }
365
+ if (isRecord(a) && isRecord(b)) {
366
+ const ak = Object.keys(a);
367
+ const bk = Object.keys(b);
368
+ if (ak.length !== bk.length) return false;
369
+ return ak.every(
370
+ (k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]),
371
+ );
372
+ }
373
+ return false;
374
+ }
375
+
376
+ function typeOfValue(v: unknown): string {
377
+ if (Array.isArray(v)) return "array";
378
+ if (typeof v === "number" && Number.isInteger(v)) return "integer";
379
+ return typeof v;
380
+ }
381
+
382
+ function matchesType(declared: string, value: unknown): boolean {
383
+ const actual = typeOfValue(value);
384
+ if (declared === "number") return actual === "number" || actual === "integer";
385
+ if (declared === "integer") return actual === "integer";
386
+ return actual === declared;
387
+ }
388
+
389
+ /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
390
+ * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
391
+ export function validateValue(
392
+ doc: OpenApiDoc,
393
+ schemaIn: OpenApiSchema | undefined,
394
+ value: unknown,
395
+ path = "",
396
+ ): ValidationIssue[] {
397
+ const schema = resolveSchema(doc, schemaIn);
398
+ if (!schema) return [];
399
+ const issues: ValidationIssue[] = [];
400
+ const at = path || "(root)";
401
+
402
+ if (value === null) {
403
+ if (schema.nullable === true || schema.type === "null") return issues;
404
+ // enum/const may explicitly permit null even without `nullable`.
405
+ if (schema.enum && schema.enum.some((e) => e === null)) return issues;
406
+ if (schema.const === null) return issues;
407
+ // Otherwise null is invalid for a typed schema, an object schema (properties/required imply
408
+ // object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
409
+ if (schema.enum) {
410
+ issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
411
+ } else if (schema.const !== undefined) {
412
+ issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
413
+ } else if (schema.type) {
414
+ issues.push({ path: at, message: `expected ${schema.type}, got null` });
415
+ } else if (isObjectSchema(schema)) {
416
+ issues.push({ path: at, message: "expected object, got null" });
417
+ }
418
+ return issues;
419
+ }
420
+
421
+ if (schema.enum && !schema.enum.some((e) => deepEqual(e, value))) {
422
+ issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
423
+ }
424
+ if (schema.const !== undefined && !deepEqual(schema.const, value)) {
425
+ issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
426
+ }
427
+
428
+ // Composition keywords — kept in lockstep with schemaToTs, which emits `allOf` as an intersection
429
+ // and `anyOf`/`oneOf` as unions. Runs before the `type` early-return since a composed schema may
430
+ // omit a top-level `type` and carry it on the sub-schemas instead.
431
+ if (schema.allOf) {
432
+ for (const sub of schema.allOf) issues.push(...validateValue(doc, sub, value, path));
433
+ }
434
+ if (schema.anyOf && !schema.anyOf.some((sub) => validateValue(doc, sub, value, path).length === 0)) {
435
+ issues.push({ path: at, message: "does not match any schema in anyOf" });
436
+ }
437
+ if (schema.oneOf) {
438
+ const matched = schema.oneOf.filter((sub) => validateValue(doc, sub, value, path).length === 0).length;
439
+ if (matched !== 1) {
440
+ issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
441
+ }
442
+ }
443
+
444
+ if (schema.type && !matchesType(schema.type, value)) {
445
+ issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
446
+ return issues; // the shape checks below assume the declared type held
447
+ }
448
+
449
+ if (schema.type === "string" && typeof value === "string") {
450
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
451
+ issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
452
+ }
453
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
454
+ issues.push({ path: at, message: `longer than maxLength ${schema.maxLength}` });
455
+ }
456
+ if (schema.pattern) {
457
+ let re: RegExp | undefined;
458
+ try {
459
+ re = new RegExp(schema.pattern);
460
+ } catch {
461
+ // A malformed pattern is a spec-author error, not client input. Surface it as a
462
+ // controlled failure (the dispatcher's outer catch maps it to a 500 with this
463
+ // message) instead of letting `new RegExp` throw an opaque error deep in
464
+ // validation and crash the host request handler.
465
+ throw new Error(`schema at ${at}: invalid pattern ${JSON.stringify(schema.pattern)}`);
466
+ }
467
+ if (!re.test(value)) {
468
+ issues.push({ path: at, message: `does not match pattern ${schema.pattern}` });
469
+ }
470
+ }
471
+ }
472
+
473
+ if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
474
+ // OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
475
+ // JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
476
+ // Support both so specs in either dialect are validated, not silently under-checked.
477
+ const minExclusive = schema.exclusiveMinimum === true;
478
+ const maxExclusive = schema.exclusiveMaximum === true;
479
+ if (schema.minimum !== undefined && (minExclusive ? value <= schema.minimum : value < schema.minimum)) {
480
+ issues.push({
481
+ path: at,
482
+ message: minExclusive ? `must be > ${schema.minimum}` : `less than minimum ${schema.minimum}`,
483
+ });
484
+ }
485
+ if (schema.maximum !== undefined && (maxExclusive ? value >= schema.maximum : value > schema.maximum)) {
486
+ issues.push({
487
+ path: at,
488
+ message: maxExclusive ? `must be < ${schema.maximum}` : `greater than maximum ${schema.maximum}`,
489
+ });
490
+ }
491
+ if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) {
492
+ issues.push({ path: at, message: `must be > ${schema.exclusiveMinimum}` });
493
+ }
494
+ if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) {
495
+ issues.push({ path: at, message: `must be < ${schema.exclusiveMaximum}` });
496
+ }
497
+ }
498
+
499
+ if (schema.type === "array" && Array.isArray(value)) {
500
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
501
+ issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
502
+ }
503
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
504
+ issues.push({ path: at, message: `more than maxItems ${schema.maxItems}` });
505
+ }
506
+ if (schema.items) {
507
+ value.forEach((item, i) => {
508
+ issues.push(...validateValue(doc, schema.items, item, `${path}[${i}]`));
509
+ });
510
+ }
511
+ }
512
+
513
+ if (isObjectSchema(schema)) {
514
+ if (!isRecord(value)) {
515
+ // A typeless object schema (properties/required imply object, but `type` is omitted) skips
516
+ // the type check above, so guard here — otherwise a non-object like `42` or `[]` would pass.
517
+ issues.push({ path: at, message: `expected object, got ${typeOfValue(value)}` });
518
+ return issues;
519
+ }
520
+ for (const req of schema.required ?? []) {
521
+ if (!Object.hasOwn(value, req)) issues.push({ path: `${path}/${req}`, message: "is required" });
522
+ }
523
+ const props = schema.properties ?? {};
524
+ for (const [key, propSchema] of Object.entries(props)) {
525
+ if (Object.hasOwn(value, key)) issues.push(...validateValue(doc, propSchema, value[key], `${path}/${key}`));
526
+ }
527
+ const extra = schema.additionalProperties;
528
+ if (extra === false) {
529
+ for (const key of Object.keys(value)) {
530
+ if (!Object.hasOwn(props, key)) issues.push({ path: `${path}/${key}`, message: "is not an allowed property" });
531
+ }
532
+ } else if (isRecord(extra)) {
533
+ for (const key of Object.keys(value)) {
534
+ if (!Object.hasOwn(props, key)) issues.push(...validateValue(doc, extra, value[key], `${path}/${key}`));
535
+ }
536
+ }
537
+ }
538
+
539
+ return issues;
540
+ }