@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.
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +32 -47
- package/dist/openapi/spec.d.ts +63 -0
- package/dist/openapi/spec.js +168 -0
- package/dist/runtime/core/modules/api.d.ts +19 -4
- package/dist/runtime/core/modules/api.js +99 -4
- package/dist/runtime/core/runtime.d.ts +10 -0
- package/dist/runtime/core/runtime.js +2 -2
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +2 -1
- package/dist/toolkit/api-dir.d.ts +11 -0
- package/dist/toolkit/api-dir.js +36 -0
- package/dist/toolkit/derivers/api.d.ts +25 -2
- package/dist/toolkit/derivers/api.js +60 -5
- package/dist/toolkit/gen.js +4 -1
- package/dist/toolkit/generate.d.ts +27 -0
- package/dist/toolkit/generate.js +47 -0
- package/dist/toolkit/index.d.ts +8 -1
- package/dist/toolkit/index.js +5 -1
- package/dist/toolkit/scaffold/operations.d.ts +21 -0
- package/dist/toolkit/scaffold/operations.js +55 -0
- package/dist/toolkit/scaffold/workers.js +1 -1
- package/dist/toolkit/scaffold.d.ts +16 -0
- package/dist/toolkit/scaffold.js +40 -0
- package/package.json +74 -74
- package/src/cli.test.ts +0 -1
- package/src/cli.ts +30 -45
- package/src/openapi/spec.test.ts +171 -0
- package/src/openapi/spec.ts +219 -1
- package/src/runtime/core/modules/api.test.ts +183 -5
- package/src/runtime/core/modules/api.ts +125 -8
- package/src/runtime/core/runtime.test.ts +53 -1
- package/src/runtime/core/runtime.ts +12 -2
- package/src/runtime/index.ts +3 -1
- package/src/toolkit/api-dir.test.ts +32 -0
- package/src/toolkit/api-dir.ts +41 -0
- package/src/toolkit/derivers/api.test.ts +41 -3
- package/src/toolkit/derivers/api.ts +75 -5
- package/src/toolkit/gen.ts +5 -2
- package/src/toolkit/generate.test.ts +135 -0
- package/src/toolkit/generate.ts +71 -0
- package/src/toolkit/index.ts +10 -0
- package/src/toolkit/scaffold/operations.test.ts +136 -0
- package/src/toolkit/scaffold/operations.ts +75 -0
- package/src/toolkit/scaffold/workers.ts +1 -1
- package/src/toolkit/scaffold.ts +56 -0
package/package.json
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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":
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -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
|
+
});
|