@nanobpm/urban 0.34.0 → 0.36.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 (46) hide show
  1. package/dist/cli.d.ts +0 -1
  2. package/dist/cli.js +32 -47
  3. package/dist/openapi/spec.d.ts +63 -0
  4. package/dist/openapi/spec.js +168 -0
  5. package/dist/runtime/core/modules/api.d.ts +19 -4
  6. package/dist/runtime/core/modules/api.js +99 -4
  7. package/dist/runtime/core/runtime.d.ts +10 -0
  8. package/dist/runtime/core/runtime.js +2 -2
  9. package/dist/runtime/index.d.ts +3 -1
  10. package/dist/runtime/index.js +2 -1
  11. package/dist/toolkit/api-dir.d.ts +11 -0
  12. package/dist/toolkit/api-dir.js +36 -0
  13. package/dist/toolkit/derivers/api.d.ts +25 -2
  14. package/dist/toolkit/derivers/api.js +60 -5
  15. package/dist/toolkit/gen.js +4 -1
  16. package/dist/toolkit/generate.d.ts +27 -0
  17. package/dist/toolkit/generate.js +47 -0
  18. package/dist/toolkit/index.d.ts +8 -1
  19. package/dist/toolkit/index.js +5 -1
  20. package/dist/toolkit/scaffold/operations.d.ts +21 -0
  21. package/dist/toolkit/scaffold/operations.js +55 -0
  22. package/dist/toolkit/scaffold/workers.js +1 -1
  23. package/dist/toolkit/scaffold.d.ts +16 -0
  24. package/dist/toolkit/scaffold.js +40 -0
  25. package/package.json +74 -74
  26. package/src/cli.test.ts +0 -1
  27. package/src/cli.ts +30 -45
  28. package/src/openapi/spec.test.ts +171 -0
  29. package/src/openapi/spec.ts +219 -1
  30. package/src/runtime/core/modules/api.test.ts +183 -5
  31. package/src/runtime/core/modules/api.ts +125 -8
  32. package/src/runtime/core/runtime.test.ts +53 -1
  33. package/src/runtime/core/runtime.ts +12 -2
  34. package/src/runtime/index.ts +3 -1
  35. package/src/toolkit/api-dir.test.ts +32 -0
  36. package/src/toolkit/api-dir.ts +41 -0
  37. package/src/toolkit/derivers/api.test.ts +41 -3
  38. package/src/toolkit/derivers/api.ts +75 -5
  39. package/src/toolkit/gen.ts +5 -2
  40. package/src/toolkit/generate.test.ts +135 -0
  41. package/src/toolkit/generate.ts +71 -0
  42. package/src/toolkit/index.ts +10 -0
  43. package/src/toolkit/scaffold/operations.test.ts +136 -0
  44. package/src/toolkit/scaffold/operations.ts +75 -0
  45. package/src/toolkit/scaffold/workers.ts +1 -1
  46. package/src/toolkit/scaffold.ts +56 -0
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
1
  {
2
- "name": "@nanobpm/urban",
3
- "version": "0.34.0",
4
- "description": "Urban: build and run code-first apps on Nano \u2014 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
- "license": "Apache-2.0",
6
- "type": "module",
7
- "keywords": [
8
- "nanobpm",
9
- "urban",
10
- "cli",
11
- "runtime",
12
- "codegen",
13
- "derivation",
14
- "nano-app",
15
- "rad"
16
- ],
17
- "publishConfig": {
18
- "access": "public"
19
- },
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/nanobpm/nano-ide.git",
23
- "directory": "packages/urban"
24
- },
25
- "engines": {
26
- "node": ">=22.6"
27
- },
28
- "bin": {
29
- "urban": "src/bin.mjs"
30
- },
31
- "main": "./dist/index.js",
32
- "types": "./dist/index.d.ts",
33
- "exports": {
34
- ".": {
35
- "types": "./dist/index.d.ts",
36
- "default": "./dist/index.js"
37
- },
38
- "./runtime": {
39
- "types": "./dist/runtime/index.d.ts",
40
- "default": "./dist/runtime/index.js"
41
- },
42
- "./toolkit": {
43
- "types": "./dist/toolkit/index.d.ts",
44
- "default": "./dist/toolkit/index.js"
45
- },
46
- "./worker": {
47
- "types": "./dist/worker.d.ts",
48
- "default": "./dist/worker.js"
49
- },
50
- "./source": "./src/index.ts"
51
- },
52
- "files": [
53
- "dist",
54
- "src",
55
- "README.md"
56
- ],
57
- "scripts": {
58
- "build": "tsc -p tsconfig.build.json",
59
- "prepack": "npm run build",
60
- "typecheck": "tsc --noEmit -p tsconfig.json",
61
- "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
62
- "test:deno": "deno test -A --no-check src/"
63
- },
64
- "dependencies": {
65
- "@nanobpm/nano-app-schema": "^0.4.0",
66
- "@nanobpm/nano-sdk": "^1.2.3",
67
- "@nanobpm/workflow": "^0.8.0",
68
- "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.9.0",
70
- "yaml": "^2.9.0"
71
- },
72
- "devDependencies": {
73
- "@types/node": "^22.20.1",
74
- "typescript": "^5.9.3"
75
- }
2
+ "name": "@nanobpm/urban",
3
+ "version": "0.36.0",
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
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "keywords": [
8
+ "nanobpm",
9
+ "urban",
10
+ "cli",
11
+ "runtime",
12
+ "codegen",
13
+ "derivation",
14
+ "nano-app",
15
+ "rad"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/nanobpm/nano-ide.git",
23
+ "directory": "packages/urban"
24
+ },
25
+ "engines": {
26
+ "node": ">=22.6"
27
+ },
28
+ "bin": {
29
+ "urban": "src/bin.mjs"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./runtime": {
39
+ "types": "./dist/runtime/index.d.ts",
40
+ "default": "./dist/runtime/index.js"
41
+ },
42
+ "./toolkit": {
43
+ "types": "./dist/toolkit/index.d.ts",
44
+ "default": "./dist/toolkit/index.js"
45
+ },
46
+ "./worker": {
47
+ "types": "./dist/worker.d.ts",
48
+ "default": "./dist/worker.js"
49
+ },
50
+ "./source": "./src/index.ts"
51
+ },
52
+ "files": [
53
+ "dist",
54
+ "src",
55
+ "README.md"
56
+ ],
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.build.json",
59
+ "prepack": "npm run build",
60
+ "typecheck": "tsc --noEmit -p tsconfig.json",
61
+ "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
62
+ "test:deno": "deno test -A --no-check src/"
63
+ },
64
+ "dependencies": {
65
+ "@nanobpm/nano-app-schema": "^0.4.0",
66
+ "@nanobpm/nano-sdk": "^1.2.3",
67
+ "@nanobpm/workflow": "^0.8.0",
68
+ "bpmn-auto-layout": "^2.0.0-alpha.2",
69
+ "create-urban-app": "^0.9.0",
70
+ "yaml": "^2.9.0"
71
+ },
72
+ "devDependencies": {
73
+ "@types/node": "^22.20.1",
74
+ "typescript": "^5.9.3"
75
+ }
76
76
  }
package/src/cli.test.ts CHANGED
@@ -14,7 +14,6 @@ test("urban data exits 0 with a parseable envelope on an unreadable request", as
14
14
  root: ".",
15
15
  manifest: "nano.app.json",
16
16
  check: false,
17
- write: false,
18
17
  deno: false,
19
18
  models: true,
20
19
  stdout: false,
package/src/cli.ts CHANGED
@@ -25,7 +25,7 @@ import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
25
25
  import { errorMessage, isRecord } from "./runtime/core/guards.ts";
26
26
  import { scaffold, slugify } from "create-urban-app";
27
27
  import type { DataRequest } from "./runtime/index.ts";
28
- import { addConnector, createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
28
+ import { addConnector, createNodeGenIO, previewModels, generate, runGen } from "./toolkit/index.ts";
29
29
  import { pathToFileURL } from "node:url";
30
30
  import { readFileSync } from "node:fs";
31
31
  import { spawnSync } from "node:child_process";
@@ -50,7 +50,6 @@ interface Flags {
50
50
  manifest: string;
51
51
  port?: number;
52
52
  check: boolean;
53
- write: boolean;
54
53
  deno: boolean;
55
54
  models: boolean;
56
55
  stdout: boolean;
@@ -62,7 +61,7 @@ interface Flags {
62
61
  }
63
62
 
64
63
  function parse(argv: string[]): Flags {
65
- const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
64
+ const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
66
65
  const need = (i: number, flag: string): string => {
67
66
  const v = argv[i];
68
67
  if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
@@ -73,7 +72,6 @@ function parse(argv: string[]): Flags {
73
72
  if (a === "-h" || a === "--help") f.help = true;
74
73
  else if (a === "-v" || a === "--version") f.version = true;
75
74
  else if (a === "--check") f.check = true;
76
- else if (a === "--write") f.write = true;
77
75
  else if (a === "--deno") f.deno = true;
78
76
  else if (a === "--no-models") f.models = false;
79
77
  else if (a === "--models") f.models = true;
@@ -108,11 +106,12 @@ Usage:
108
106
  urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
109
107
  (--code-first is shorthand for --style code)
110
108
  urban check validate the manifest
111
- urban gen [--check] [--no-models] derive artifacts (migrations, worker-io, + code-first models)
109
+ urban gen [--check] [--no-models] derive artifacts + scaffold write-once handler stubs
110
+ (--check: verify up-to-date without writing; a missing stub or
111
+ drifted artifact fails — run \`urban gen\`)
112
112
  (--no-models: skip writing derived .bpmn; type-contracts only)
113
113
  urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
114
114
  (--stdout: print {models,incomplete} JSON without writing)
115
- urban stubs [--write] scaffold write-once handler stubs from the model
116
115
  urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
117
116
  urban run materialize + serve the app
118
117
  urban dev run + watch sources, hot-reload on change
@@ -148,23 +147,43 @@ async function cmdCheck(f: Flags): Promise<number> {
148
147
 
149
148
  async function cmdGen(f: Flags): Promise<number> {
150
149
  const io = createNodeGenIO();
151
- const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
150
+ const res = await generate({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
152
151
  if (res.incomplete) {
153
152
  console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
154
153
  for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
155
154
  return 1;
156
155
  }
157
156
  if (f.check) {
158
- if (res.drift.length === 0) {
159
- console.log(`✔ generated artifacts are up to date (${res.artifacts.length} checked)`);
157
+ if (res.drift.length === 0 && res.missingStubs.length === 0) {
158
+ console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
160
159
  return 0;
161
160
  }
162
- console.error(`✖ ${res.drift.length} generated artifact(s) are out of date — run \`urban gen\`:`);
163
- for (const p of res.drift) console.error(` • ${p}`);
161
+ if (res.drift.length > 0) {
162
+ console.error(`✖ ${res.drift.length} generated artifact(s) are out of date — run \`urban gen\`:`);
163
+ for (const p of res.drift) console.error(` • ${p}`);
164
+ }
165
+ if (res.missingStubs.length > 0) {
166
+ console.error(`✖ ${res.missingStubs.length} handler stub(s) missing — run \`urban gen\`:`);
167
+ for (const p of res.missingStubs) console.error(` • ${p}`);
168
+ }
164
169
  return 1;
165
170
  }
166
171
  console.log(`✔ generated ${res.artifacts.length} artifact(s):`);
167
172
  for (const a of res.artifacts) console.log(` • ${a.path}`);
173
+
174
+ // Write-once handler stubs scaffolded as part of generation (workers from the model, operation
175
+ // delegates from the OpenAPI spec). Existing, human-owned stubs are kept verbatim.
176
+ const created = [
177
+ ...res.workerStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
178
+ ...res.operationStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
179
+ ];
180
+ if (created.length > 0) {
181
+ console.log(`✔ scaffolded ${created.length} handler stub(s):`);
182
+ for (const p of created) console.log(` + ${p}`);
183
+ }
184
+ if (res.manifestPatched) {
185
+ console.log(`✔ wired ${res.wiredWorkers.length} worker(s) into ${f.manifest}`);
186
+ }
168
187
  return 0;
169
188
  }
170
189
 
@@ -217,38 +236,6 @@ async function cmdDerive(f: Flags): Promise<number> {
217
236
  return 0;
218
237
  }
219
238
 
220
- async function cmdStubs(f: Flags): Promise<number> {
221
- const io = createNodeGenIO();
222
- const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
223
-
224
- const created = run.outcomes.filter((o) => o.status === "created");
225
- const would = run.outcomes.filter((o) => o.status === "would-create");
226
- const kept = run.outcomes.filter((o) => o.status === "kept");
227
- const typing = (o: { typedIn: boolean; typedOut: boolean }) =>
228
- o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
229
-
230
- if (run.write) {
231
- console.log(`✔ scaffolded ${created.length} worker stub(s):`);
232
- for (const o of created) console.log(` + ${o.handlerPath}${typing(o)}`);
233
- if (run.manifestPatched) {
234
- console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
235
- }
236
- } else {
237
- if (would.length === 0) {
238
- console.log(`✔ no new worker stubs to scaffold`);
239
- } else {
240
- console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
241
- for (const o of would) console.log(` + ${o.handlerPath}${typing(o)}`);
242
- }
243
- }
244
- for (const o of kept) console.log(` = ${o.handlerPath} (kept — already exists)`);
245
- if (run.skipped.length > 0) {
246
- console.log(` skipped ${run.skipped.length}: ` +
247
- run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
248
- }
249
- return 0;
250
- }
251
-
252
239
  async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
253
240
  const app = await runFromEnv({
254
241
  root: f.root,
@@ -430,8 +417,6 @@ export async function main(argv: string[]): Promise<number> {
430
417
  return cmdGen(f);
431
418
  case "derive":
432
419
  return cmdDerive(f);
433
- case "stubs":
434
- return cmdStubs(f);
435
420
  case "add":
436
421
  return cmdAdd(f);
437
422
  case "data":
@@ -2,6 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  collectOperations,
5
+ evaluateSecurity,
5
6
  isObjectSchema,
6
7
  isSafeOperationId,
7
8
  type OpenApiDoc,
@@ -12,6 +13,7 @@ import {
12
13
  resolveSchema,
13
14
  toRouteMatcher,
14
15
  undeclaredPathParams,
16
+ undeclaredSecuritySchemes,
15
17
  validateValue,
16
18
  } from "./spec.ts";
17
19
 
@@ -346,3 +348,172 @@ test("validateValue handles null: typeless object rejects it; nullable/type-null
346
348
  // A truly empty schema allows anything, including null.
347
349
  assert.equal(validateValue(doc, {}, null).length, 0);
348
350
  });
351
+
352
+ // ── Security (ADR 0059: one HTTP surface, apiKey-guarded webhook operations) ──────────────────
353
+
354
+ const secured: OpenApiDoc = {
355
+ openapi: "3.0.0",
356
+ components: {
357
+ securitySchemes: {
358
+ webhookKey: {
359
+ type: "apiKey",
360
+ in: "header",
361
+ name: "X-Webhook-Key",
362
+ "x-nano-secret-env": "NANO_WEBHOOK_KEY",
363
+ },
364
+ queryKey: {
365
+ type: "apiKey",
366
+ in: "query",
367
+ name: "key",
368
+ "x-nano-secret-env": "NANO_QUERY_KEY",
369
+ },
370
+ },
371
+ },
372
+ security: [{ webhookKey: [] }], // document-level default
373
+ paths: {
374
+ "/hooks/submit": {
375
+ post: { operationId: "submitHook", responses: { "204": {} } }, // inherits the default
376
+ },
377
+ "/hooks/open": {
378
+ post: { operationId: "openHook", security: [], responses: { "204": {} } }, // opts out
379
+ },
380
+ "/hooks/query": {
381
+ post: {
382
+ operationId: "queryHook",
383
+ security: [{ queryKey: [] }],
384
+ responses: { "204": {} },
385
+ },
386
+ },
387
+ },
388
+ };
389
+
390
+ const noHeader = (): undefined => undefined;
391
+ const noQuery = (): undefined => undefined;
392
+ const secret = (vars: Record<string, string>) => (v: string): string | undefined => vars[v];
393
+
394
+ test("collectOperations resolves effective security (op-level wins, explicit [] opts out, doc default inherited)", () => {
395
+ const ops = collectOperations(secured);
396
+ const byId = Object.fromEntries(ops.map((o) => [o.operationId, o]));
397
+ assert.deepEqual(byId.submitHook.security, [{ webhookKey: [] }]); // inherited default
398
+ assert.deepEqual(byId.openHook.security, []); // explicit opt-out
399
+ assert.deepEqual(byId.queryHook.security, [{ queryKey: [] }]); // own requirement
400
+ });
401
+
402
+ test("evaluateSecurity: an open operation (no requirement) is always authorized", () => {
403
+ const open = collectOperations(secured).find((o) => o.operationId === "openHook")!;
404
+ assert.deepEqual(
405
+ evaluateSecurity(secured, open, noHeader, noQuery, secret({})),
406
+ { ok: true },
407
+ );
408
+ });
409
+
410
+ test("evaluateSecurity: a valid apiKey header authorizes; wrong/absent → 401", () => {
411
+ const op = collectOperations(secured).find((o) => o.operationId === "submitHook")!;
412
+ const env = secret({ NANO_WEBHOOK_KEY: "s3cret" });
413
+ // Correct key.
414
+ assert.equal(
415
+ evaluateSecurity(secured, op, (n) => (n === "X-Webhook-Key" ? "s3cret" : undefined), noQuery, env).ok,
416
+ true,
417
+ );
418
+ // Wrong key → 401.
419
+ assert.deepEqual(
420
+ evaluateSecurity(secured, op, () => "nope", noQuery, env),
421
+ { ok: false, status: 401, error: "unauthorized" },
422
+ );
423
+ // Missing key → 401.
424
+ assert.deepEqual(evaluateSecurity(secured, op, noHeader, noQuery, env), {
425
+ ok: false,
426
+ status: 401,
427
+ error: "unauthorized",
428
+ });
429
+ });
430
+
431
+ test("evaluateSecurity: an apiKey in query is read from the query, not the header", () => {
432
+ const op = collectOperations(secured).find((o) => o.operationId === "queryHook")!;
433
+ const env = secret({ NANO_QUERY_KEY: "qk" });
434
+ assert.equal(
435
+ evaluateSecurity(secured, op, noHeader, (n) => (n === "key" ? "qk" : undefined), env).ok,
436
+ true,
437
+ );
438
+ assert.equal(evaluateSecurity(secured, op, noHeader, () => "bad", env).status, 401);
439
+ });
440
+
441
+ test("evaluateSecurity: an unset secret env is a 500 misconfiguration (fail closed, not 401)", () => {
442
+ const op = collectOperations(secured).find((o) => o.operationId === "submitHook")!;
443
+ const decision = evaluateSecurity(secured, op, () => "anything", noQuery, secret({}));
444
+ assert.equal(decision.ok, false);
445
+ assert.equal(decision.status, 500);
446
+ assert.match(decision.error ?? "", /NANO_WEBHOOK_KEY is not set/);
447
+ });
448
+
449
+ test("evaluateSecurity: a requirement naming an undeclared scheme is a 500 misconfiguration", () => {
450
+ const doc: OpenApiDoc = {
451
+ openapi: "3.0.0",
452
+ components: { securitySchemes: {} },
453
+ paths: {
454
+ "/x": { post: { operationId: "x", security: [{ ghost: [] }], responses: { "204": {} } } },
455
+ },
456
+ };
457
+ const op = collectOperations(doc)[0];
458
+ const decision = evaluateSecurity(doc, op, () => "k", noQuery, secret({}));
459
+ assert.equal(decision.status, 500);
460
+ assert.match(decision.error ?? "", /not declared/);
461
+ assert.deepEqual(undeclaredSecuritySchemes(doc), ["ghost (POST /x)"]);
462
+ });
463
+
464
+
465
+ test("evaluateSecurity: malformed security entries fail closed as a 500 misconfiguration", () => {
466
+ const doc: OpenApiDoc = {
467
+ openapi: "3.0.0",
468
+ components: {
469
+ securitySchemes: { webhookKey: { type: "apiKey", in: "header", name: "X-Key", "x-nano-secret-env": "KEY" } },
470
+ },
471
+ paths: {
472
+ "/x": { post: { operationId: "x", security: ["webhookKey"], responses: { "204": {} } } },
473
+ },
474
+ };
475
+ const op = collectOperations(doc)[0];
476
+ const decision = evaluateSecurity(doc, op, () => "secret", noQuery, secret({ KEY: "secret" }));
477
+ assert.equal(decision.status, 500);
478
+ assert.match(decision.error ?? "", /malformed security requirement/);
479
+ });
480
+
481
+ test("evaluateSecurity: an unsupported scheme type is a 500 misconfiguration", () => {
482
+ const doc: OpenApiDoc = {
483
+ openapi: "3.0.0",
484
+ components: { securitySchemes: { oauth: { type: "oauth2" } } },
485
+ paths: {
486
+ "/x": { post: { operationId: "x", security: [{ oauth: [] }], responses: { "204": {} } } },
487
+ },
488
+ };
489
+ const op = collectOperations(doc)[0];
490
+ const decision = evaluateSecurity(doc, op, () => "k", noQuery, secret({}));
491
+ assert.equal(decision.status, 500);
492
+ assert.match(decision.error ?? "", /unsupported type "oauth2"/);
493
+ });
494
+
495
+ test("evaluateSecurity: alternative requirements are OR (any one satisfied authorizes)", () => {
496
+ const doc: OpenApiDoc = {
497
+ openapi: "3.0.0",
498
+ components: {
499
+ securitySchemes: {
500
+ a: { type: "apiKey", in: "header", name: "A", "x-nano-secret-env": "A_KEY" },
501
+ b: { type: "apiKey", in: "header", name: "B", "x-nano-secret-env": "B_KEY" },
502
+ },
503
+ },
504
+ paths: {
505
+ "/x": {
506
+ post: { operationId: "x", security: [{ a: [] }, { b: [] }], responses: { "204": {} } },
507
+ },
508
+ },
509
+ };
510
+ const op = collectOperations(doc)[0];
511
+ const env = secret({ A_KEY: "aa", B_KEY: "bb" });
512
+ // Only B presented → still authorized (OR).
513
+ assert.equal(
514
+ evaluateSecurity(doc, op, (n) => (n === "B" ? "bb" : undefined), noQuery, env).ok,
515
+ true,
516
+ );
517
+ // Neither presented → 401.
518
+ assert.equal(evaluateSecurity(doc, op, noHeader, noQuery, env).status, 401);
519
+ });