@nanobpm/urban 0.28.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 (84) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/adapters/deno.js +5 -0
  4. package/dist/runtime/adapters/node.js +5 -0
  5. package/dist/runtime/core/execContext.d.ts +28 -0
  6. package/dist/runtime/core/execContext.js +39 -0
  7. package/dist/runtime/core/host.d.ts +41 -0
  8. package/dist/runtime/core/manifest.d.ts +1 -1
  9. package/dist/runtime/core/modules/api.d.ts +61 -0
  10. package/dist/runtime/core/modules/api.js +359 -0
  11. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  12. package/dist/runtime/core/modules/cancel.js +90 -0
  13. package/dist/runtime/core/modules/connectors.js +3 -1
  14. package/dist/runtime/core/modules/datasource.d.ts +23 -3
  15. package/dist/runtime/core/modules/datasource.js +61 -6
  16. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  17. package/dist/runtime/core/modules/gateway.js +23 -6
  18. package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
  19. package/dist/runtime/core/modules/instance-tracking.js +158 -0
  20. package/dist/runtime/core/modules/pages.d.ts +8 -0
  21. package/dist/runtime/core/modules/pages.js +17 -1
  22. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  23. package/dist/runtime/core/modules/scheduler.js +21 -0
  24. package/dist/runtime/core/modules/surfaces.js +9 -0
  25. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  26. package/dist/runtime/core/modules/triggers.js +1 -16
  27. package/dist/runtime/core/modules/workers.js +3 -2
  28. package/dist/runtime/core/runtime.d.ts +3 -0
  29. package/dist/runtime/core/runtime.js +12 -0
  30. package/dist/runtime/core/validate.js +38 -0
  31. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  32. package/dist/runtime/engine/nanosdk.js +90 -7
  33. package/dist/runtime/index.d.ts +4 -0
  34. package/dist/runtime/index.js +2 -0
  35. package/dist/toolkit/artifact.d.ts +5 -0
  36. package/dist/toolkit/artifact.js +7 -0
  37. package/dist/toolkit/derivers/api.d.ts +19 -0
  38. package/dist/toolkit/derivers/api.js +224 -0
  39. package/dist/toolkit/gen.js +23 -4
  40. package/dist/toolkit/index.d.ts +5 -0
  41. package/dist/toolkit/index.js +12 -1
  42. package/package.json +2 -2
  43. package/src/openapi/spec.test.ts +292 -0
  44. package/src/openapi/spec.ts +540 -0
  45. package/src/runtime/adapters/deno.ts +6 -0
  46. package/src/runtime/adapters/node.ts +6 -0
  47. package/src/runtime/core/execContext.ts +56 -0
  48. package/src/runtime/core/host.ts +44 -0
  49. package/src/runtime/core/manifest.ts +1 -0
  50. package/src/runtime/core/modules/actions.test.ts +1 -0
  51. package/src/runtime/core/modules/api.test.ts +445 -0
  52. package/src/runtime/core/modules/api.ts +453 -0
  53. package/src/runtime/core/modules/cancel.test.ts +310 -0
  54. package/src/runtime/core/modules/cancel.ts +119 -0
  55. package/src/runtime/core/modules/connectors.test.ts +1 -0
  56. package/src/runtime/core/modules/connectors.ts +7 -1
  57. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  58. package/src/runtime/core/modules/datasource.ts +77 -6
  59. package/src/runtime/core/modules/gateway.ts +28 -6
  60. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  61. package/src/runtime/core/modules/instance-tracking.ts +180 -0
  62. package/src/runtime/core/modules/pages.test.ts +47 -2
  63. package/src/runtime/core/modules/pages.ts +23 -1
  64. package/src/runtime/core/modules/provenance.test.ts +181 -0
  65. package/src/runtime/core/modules/scheduler.ts +31 -0
  66. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  67. package/src/runtime/core/modules/surfaces.ts +10 -0
  68. package/src/runtime/core/modules/triggers.test.ts +1 -0
  69. package/src/runtime/core/modules/triggers.ts +3 -25
  70. package/src/runtime/core/modules/workers.test.ts +1 -0
  71. package/src/runtime/core/modules/workers.ts +11 -2
  72. package/src/runtime/core/runtime.test.ts +3 -0
  73. package/src/runtime/core/runtime.ts +16 -0
  74. package/src/runtime/core/validate.test.ts +96 -0
  75. package/src/runtime/core/validate.ts +39 -0
  76. package/src/runtime/engine/nanosdk.test.ts +118 -5
  77. package/src/runtime/engine/nanosdk.ts +102 -7
  78. package/src/runtime/index.ts +12 -0
  79. package/src/toolkit/artifact.ts +8 -0
  80. package/src/toolkit/derivers/api.test.ts +219 -0
  81. package/src/toolkit/derivers/api.ts +255 -0
  82. package/src/toolkit/gen.test.ts +39 -0
  83. package/src/toolkit/gen.ts +24 -4
  84. package/src/toolkit/index.ts +41 -1
@@ -11,13 +11,24 @@ export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, ov
11
11
  export { byModelPath, typeRefFor } from "./derivers/worker-io.js";
12
12
  export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.js";
13
13
  export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.js";
14
+ export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, } from "./derivers/api.js";
15
+ // The OpenAPI machinery the deriver + runtime share (ADR 0058).
16
+ export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.js";
14
17
  // The registry of all derivers (for discovery / IDE migration).
15
18
  import { migrationsDeriver } from "./derivers/migrations.js";
16
19
  import { domainDeriver } from "./derivers/domain.js";
17
20
  import { workerIoDeriver } from "./derivers/worker-io.js";
18
21
  import { metaDeriver } from "./derivers/meta.js";
19
22
  import { messagesDeriver } from "./derivers/messages.js";
20
- export const DERIVERS = [migrationsDeriver, domainDeriver, workerIoDeriver, metaDeriver, messagesDeriver];
23
+ import { apiDeriver } from "./derivers/api.js";
24
+ export const DERIVERS = [
25
+ migrationsDeriver,
26
+ domainDeriver,
27
+ workerIoDeriver,
28
+ metaDeriver,
29
+ messagesDeriver,
30
+ apiDeriver,
31
+ ];
21
32
  // Gen orchestrator + IO
22
33
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.js";
23
34
  export { createNodeGenIO } from "./fsio.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -62,7 +62,7 @@
62
62
  "test:deno": "deno test -A --no-check src/"
63
63
  },
64
64
  "dependencies": {
65
- "@nanobpm/nano-app-schema": "^0.3.0",
65
+ "@nanobpm/nano-app-schema": "^0.4.0",
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
@@ -0,0 +1,292 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ collectOperations,
5
+ isObjectSchema,
6
+ isSafeOperationId,
7
+ type OpenApiDoc,
8
+ type OpenApiSchema,
9
+ operationsWithoutId,
10
+ operationsWithUnsafeId,
11
+ parseSpec,
12
+ resolveSchema,
13
+ toRouteMatcher,
14
+ undeclaredPathParams,
15
+ validateValue,
16
+ } from "./spec.ts";
17
+
18
+ const doc: OpenApiDoc = {
19
+ openapi: "3.0.0",
20
+ components: {
21
+ schemas: {
22
+ Invoice: {
23
+ type: "object",
24
+ properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
25
+ required: ["id", "amount"],
26
+ additionalProperties: false,
27
+ },
28
+ },
29
+ },
30
+ paths: {
31
+ "/invoices": {
32
+ get: {
33
+ operationId: "listInvoices",
34
+ parameters: [{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }],
35
+ responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } } },
36
+ },
37
+ post: {
38
+ operationId: "createInvoice",
39
+ requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
40
+ responses: { "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } } },
41
+ },
42
+ },
43
+ "/invoices/{id}": {
44
+ get: {
45
+ operationId: "getInvoice",
46
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
47
+ responses: { "200": {} },
48
+ },
49
+ delete: { responses: { "204": {} } }, // no operationId — skipped + reported
50
+ },
51
+ },
52
+ };
53
+
54
+ test("parseSpec throws a clear error on non-JSON and non-object", () => {
55
+ assert.throws(() => parseSpec("not json"), /not valid JSON/);
56
+ assert.throws(() => parseSpec("42"), /must be a JSON object/);
57
+ });
58
+
59
+ test("collectOperations returns id-bearing ops in stable (path, method) order", () => {
60
+ const ops = collectOperations(doc);
61
+ assert.deepEqual(
62
+ ops.map((o) => o.operationId),
63
+ ["listInvoices", "createInvoice", "getInvoice"],
64
+ );
65
+ const create = ops.find((o) => o.operationId === "createInvoice");
66
+ assert.equal(create?.method, "post");
67
+ assert.equal(create?.requestBodyRequired, true);
68
+ assert.ok(create?.requestBodySchema);
69
+ const get = ops.find((o) => o.operationId === "getInvoice");
70
+ assert.equal(get?.parameters[0]?.in, "path");
71
+ });
72
+
73
+ test("operationsWithoutId reports the delete with no operationId", () => {
74
+ assert.deepEqual(operationsWithoutId(doc), ["DELETE /invoices/{id}"]);
75
+ });
76
+
77
+ test("collectOperations dedups path- and op-level params by (name, in), op-level winning", () => {
78
+ // OpenAPI: a param is identified by (name, in); an op-level param overrides the path-level one of
79
+ // the same identity — it must never appear twice, or the emitted params/query binding would carry
80
+ // two colliding fields and runtime extraction would be ambiguous.
81
+ const dedupDoc: OpenApiDoc = {
82
+ openapi: "3.0.0",
83
+ paths: {
84
+ "/items/{id}": {
85
+ // Path-level: id is (loosely) optional here; op-level tightens it to required.
86
+ parameters: [
87
+ { name: "id", in: "path", required: false, schema: { type: "string" } },
88
+ { name: "trace", in: "query", schema: { type: "string" } },
89
+ ],
90
+ get: {
91
+ operationId: "getItem",
92
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
93
+ responses: { "200": {} },
94
+ },
95
+ },
96
+ },
97
+ };
98
+ const op = collectOperations(dedupDoc).find((o) => o.operationId === "getItem");
99
+ assert.ok(op);
100
+ const ids = op.parameters.filter((p) => p.in === "path" && p.name === "id");
101
+ assert.equal(ids.length, 1, "the (id, path) param must collapse to one");
102
+ // Op-level wins: required + integer schema, not the path-level optional string.
103
+ assert.equal(ids[0]?.required, true);
104
+ assert.equal(ids[0]?.schema?.type, "integer");
105
+ // First-seen position is preserved: id (from path-level) before the query param.
106
+ assert.deepEqual(
107
+ op.parameters.map((p) => `${p.in}:${p.name}`),
108
+ ["path:id", "query:trace"],
109
+ );
110
+ });
111
+
112
+ test("resolveSchema follows a local $ref and guards cycles", () => {
113
+ const resolved = resolveSchema(doc, { $ref: "#/components/schemas/Invoice" });
114
+ assert.equal(resolved?.type, "object");
115
+ const cyclic: OpenApiDoc = { openapi: "3.0.0", components: { schemas: { A: { $ref: "#/components/schemas/A" } } }, paths: {} };
116
+ assert.equal(resolveSchema(cyclic, { $ref: "#/components/schemas/A" }), undefined);
117
+ });
118
+
119
+ test("validateValue enforces the JSON-Schema subset via $ref", () => {
120
+ const schema = { $ref: "#/components/schemas/Invoice" };
121
+ assert.deepEqual(validateValue(doc, schema, { id: "i1", amount: 5 }, "body"), []);
122
+ // missing required + wrong type + disallowed extra property
123
+ const issues = validateValue(doc, schema, { amount: "nope", extra: 1 }, "body");
124
+ const paths = issues.map((i) => i.path).sort();
125
+ assert.deepEqual(paths, ["body/amount", "body/extra", "body/id"]);
126
+ });
127
+
128
+ test("validateValue numeric bounds + enum", () => {
129
+ assert.equal(validateValue(doc, { type: "integer", minimum: 1, maximum: 100 }, 5).length, 0);
130
+ assert.equal(validateValue(doc, { type: "integer", maximum: 100 }, 500).length, 1);
131
+ assert.equal(validateValue(doc, { type: "string", enum: ["a", "b"] }, "c").length, 1);
132
+ });
133
+
134
+ test("toRouteMatcher captures path params positionally and boundary-matches", () => {
135
+ const { pattern, paramNames } = toRouteMatcher("/app/api", "/invoices/{id}");
136
+ assert.deepEqual(paramNames, ["id"]);
137
+ const m = pattern.exec("/app/api/invoices/42");
138
+ assert.equal(m?.[1], "42");
139
+ assert.equal(pattern.test("/app/api/invoices/42/extra"), false);
140
+ assert.equal(pattern.test("/app/api/invoices/42/"), true); // optional trailing slash
141
+ });
142
+
143
+ test("isObjectSchema treats typeless property/required/additionalProperties schemas as objects", () => {
144
+ assert.equal(isObjectSchema({ type: "object" }), true);
145
+ assert.equal(isObjectSchema({ properties: { a: { type: "string" } } }), true);
146
+ assert.equal(isObjectSchema({ required: ["a"] }), true);
147
+ assert.equal(isObjectSchema({ additionalProperties: false }), true);
148
+ assert.equal(isObjectSchema({ type: "string" }), false); // explicit non-object type wins
149
+ assert.equal(isObjectSchema({}), false); // no shape keywords → not an object
150
+ });
151
+
152
+ test("validateValue shape-checks typeless object schemas (no type/runtime drift)", () => {
153
+ // A schema with properties/required but no explicit `type: "object"` is emitted as an object
154
+ // by schemaToTs, so the validator must shape-check it too — otherwise invalid bodies pass.
155
+ const schema = { properties: { id: { type: "string" } }, required: ["id"] };
156
+ assert.deepEqual(validateValue(doc, schema, { id: "ok" }, "body"), []);
157
+ const issues = validateValue(doc, schema, {}, "body");
158
+ assert.deepEqual(
159
+ issues.map((i) => i.path),
160
+ ["body/id"],
161
+ );
162
+ });
163
+
164
+ test("validateValue surfaces a malformed pattern as a controlled throw, not an opaque RegExp error", () => {
165
+ assert.throws(
166
+ () => validateValue(doc, { type: "string", pattern: "([" }, "abc", "body"),
167
+ /invalid pattern/,
168
+ );
169
+ });
170
+
171
+ test("a typeless object schema rejects non-object values (42, [])", () => {
172
+ const schema = { properties: { id: { type: "string" } }, required: ["id"] };
173
+ assert.equal(validateValue(doc, schema, 42, "body").length, 1);
174
+ assert.equal(validateValue(doc, schema, [], "body").length, 1);
175
+ assert.equal(validateValue(doc, schema, { id: "x" }, "body").length, 0);
176
+ });
177
+
178
+ test("validateValue supports OpenAPI 3.0 boolean exclusiveMinimum/Maximum and 3.1 numeric form", () => {
179
+ // 3.0 boolean form: exclusiveMinimum: true makes `minimum` exclusive
180
+ assert.equal(validateValue(doc, { type: "integer", minimum: 1, exclusiveMinimum: true }, 1).length, 1);
181
+ assert.equal(validateValue(doc, { type: "integer", minimum: 1, exclusiveMinimum: true }, 2).length, 0);
182
+ assert.equal(validateValue(doc, { type: "integer", maximum: 10, exclusiveMaximum: true }, 10).length, 1);
183
+ // inclusive by default
184
+ assert.equal(validateValue(doc, { type: "integer", minimum: 1 }, 1).length, 0);
185
+ // 3.1 numeric form still works
186
+ assert.equal(validateValue(doc, { type: "integer", exclusiveMinimum: 1 }, 1).length, 1);
187
+ assert.equal(validateValue(doc, { type: "integer", exclusiveMinimum: 1 }, 2).length, 0);
188
+ });
189
+ test("validateValue compares enum/const structurally (object/array members, not by reference)", () => {
190
+ // Parsed request values are never `===` to the schema's object/array members, so `===` would
191
+ // reject even a structurally-identical value. Deep equality is required for correctness.
192
+ const objEnum = { type: "object", enum: [{ kind: "a", n: 1 }] };
193
+ assert.equal(validateValue(doc, objEnum, { kind: "a", n: 1 }).length, 0);
194
+ assert.equal(validateValue(doc, objEnum, { kind: "b", n: 1 }).length, 1);
195
+ const arrConst = { const: [1, 2, 3] };
196
+ assert.equal(validateValue(doc, arrConst, [1, 2, 3]).length, 0);
197
+ assert.equal(validateValue(doc, arrConst, [1, 2]).length, 1);
198
+ // primitives still work
199
+ assert.equal(validateValue(doc, { type: "string", enum: ["a", "b"] }, "a").length, 0);
200
+ });
201
+
202
+ test("toRouteMatcher escapes RegExp metacharacters in the base (literal path prefix)", () => {
203
+ const { pattern } = toRouteMatcher("/app/api(v2)", "/invoices/{id}");
204
+ // The parentheses in the base must match literally, not act as a regex group.
205
+ assert.equal(pattern.test("/app/api(v2)/invoices/42"), true);
206
+ assert.equal(pattern.test("/app/apiv2/invoices/42"), false);
207
+ // A dot in the base must not match an arbitrary character.
208
+ const { pattern: p2 } = toRouteMatcher("/v1.0", "/ping");
209
+ assert.equal(p2.test("/v1.0/ping"), true);
210
+ assert.equal(p2.test("/v1x0/ping"), false);
211
+ });
212
+
213
+ test("validateValue enforces allOf (intersection), anyOf/oneOf (union) — matching schemaToTs", () => {
214
+ // allOf: value must satisfy every subschema (intersection).
215
+ const all: OpenApiSchema = { allOf: [{ type: "object", properties: { a: { type: "string" } }, required: ["a"] }, { type: "object", properties: { b: { type: "integer" } }, required: ["b"] }] };
216
+ assert.equal(validateValue(doc, all, { a: "x", b: 1 }).length, 0);
217
+ assert.ok(validateValue(doc, all, { a: "x" }).length > 0); // missing b
218
+ // anyOf: at least one subschema must match.
219
+ const any: OpenApiSchema = { anyOf: [{ type: "string" }, { type: "integer" }] };
220
+ assert.equal(validateValue(doc, any, "x").length, 0);
221
+ assert.equal(validateValue(doc, any, 5).length, 0);
222
+ assert.ok(validateValue(doc, any, true).length > 0);
223
+ // oneOf: exactly one subschema must match.
224
+ const one: OpenApiSchema = { oneOf: [{ type: "integer" }, { type: "integer", minimum: 10 }] };
225
+ assert.equal(validateValue(doc, one, 5).length, 0); // integer but <10 → matches only the first
226
+ assert.ok(validateValue(doc, one, 20).length > 0); // matches BOTH → not exactly one
227
+ assert.ok(validateValue(doc, one, "x").length > 0); // matches NEITHER
228
+ });
229
+
230
+ test("undeclaredPathParams flags path-template params with no declared parameter", () => {
231
+ const drift: OpenApiDoc = {
232
+ openapi: "3.0.0",
233
+ paths: {
234
+ "/users/{userId}/posts/{postId}": {
235
+ get: {
236
+ operationId: "getUserPost",
237
+ parameters: [{ name: "userId", in: "path", required: true, schema: { type: "string" } }],
238
+ responses: { "200": {} },
239
+ },
240
+ },
241
+ },
242
+ };
243
+ assert.deepEqual(undeclaredPathParams(drift), ["GET /users/{userId}/posts/{postId} {postId}"]);
244
+ // A fully-declared spec reports nothing.
245
+ assert.deepEqual(undeclaredPathParams(doc), []);
246
+ });
247
+
248
+ test("isSafeOperationId rejects path separators and traversal; collectOperations skips them", () => {
249
+ assert.equal(isSafeOperationId("getInvoice"), true);
250
+ assert.equal(isSafeOperationId("get.invoice-v2"), true);
251
+ assert.equal(isSafeOperationId("../secret"), false);
252
+ assert.equal(isSafeOperationId("a/b"), false);
253
+ assert.equal(isSafeOperationId("a\\b"), false);
254
+ assert.equal(isSafeOperationId(".."), false);
255
+ const evil: OpenApiDoc = {
256
+ openapi: "3.0.0",
257
+ paths: {
258
+ "/x": { get: { operationId: "../../etc/passwd", responses: { "200": {} } } },
259
+ "/y": { get: { operationId: "safeOp", responses: { "200": {} } } },
260
+ },
261
+ };
262
+ assert.deepEqual(collectOperations(evil).map((o) => o.operationId), ["safeOp"]);
263
+ assert.deepEqual(operationsWithUnsafeId(evil), ["GET /x (../../etc/passwd)"]);
264
+ });
265
+
266
+ test("object validation uses own-property checks (prototype keys don't satisfy required or bypass additionalProperties)", () => {
267
+ // A prototype key must NOT satisfy `required` (own-property semantics).
268
+ const req = { type: "object", properties: { toString: { type: "string" } }, required: ["toString"] };
269
+ assert.equal(validateValue(doc, req, {}).length, 1); // `toString` inherited, not provided
270
+ assert.equal(validateValue(doc, req, { toString: "x" }).length, 0);
271
+ // A prototype-named key in the payload must be rejected under additionalProperties:false.
272
+ const closed = { type: "object", properties: { id: { type: "string" } }, additionalProperties: false };
273
+ const issues = validateValue(doc, closed, { id: "x", toString: "evil" });
274
+ assert.ok(issues.some((i) => i.message === "is not an allowed property"));
275
+ });
276
+
277
+ test("validateValue handles null: typeless object rejects it; nullable/type-null/enum-null/const-null allow it", () => {
278
+ // A typeless object schema (properties/required imply object) must reject null.
279
+ const objSchema: OpenApiSchema = { properties: { id: { type: "string" } }, required: ["id"] };
280
+ assert.equal(validateValue(doc, objSchema, null).length, 1);
281
+ // A typed schema rejects null unless nullable / type: "null".
282
+ assert.equal(validateValue(doc, { type: "string" }, null).length, 1);
283
+ assert.equal(validateValue(doc, { type: "string", nullable: true }, null).length, 0);
284
+ assert.equal(validateValue(doc, { type: "null" }, null).length, 0);
285
+ // enum/const gate null explicitly.
286
+ assert.equal(validateValue(doc, { enum: [null, "a"] }, null).length, 0);
287
+ assert.equal(validateValue(doc, { enum: ["a", "b"] }, null).length, 1);
288
+ assert.equal(validateValue(doc, { const: null }, null).length, 0);
289
+ assert.equal(validateValue(doc, { const: "a" }, null).length, 1);
290
+ // A truly empty schema allows anything, including null.
291
+ assert.equal(validateValue(doc, {}, null).length, 0);
292
+ });