@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/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { collectManifestIssues, installSignalHandlers, loadManifest, runDataOp,
|
|
|
15
15
|
import { denoGlobal, processGlobal } from "./runtime/adapters/globals.js";
|
|
16
16
|
import { errorMessage, isRecord } from "./runtime/core/guards.js";
|
|
17
17
|
import { scaffold, slugify } from "create-urban-app";
|
|
18
|
-
import { addConnector, createNodeGenIO, previewModels,
|
|
18
|
+
import { addConnector, createNodeGenIO, previewModels, generate, runGen } from "./toolkit/index.js";
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
@@ -33,7 +33,7 @@ function readVersion() {
|
|
|
33
33
|
}
|
|
34
34
|
const VERSION = readVersion();
|
|
35
35
|
function parse(argv) {
|
|
36
|
-
const f = { root: ".", manifest: "nano.app.json", check: false,
|
|
36
|
+
const f = { root: ".", manifest: "nano.app.json", check: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
|
|
37
37
|
const need = (i, flag) => {
|
|
38
38
|
const v = argv[i];
|
|
39
39
|
if (v === undefined || v.startsWith("-"))
|
|
@@ -48,8 +48,6 @@ function parse(argv) {
|
|
|
48
48
|
f.version = true;
|
|
49
49
|
else if (a === "--check")
|
|
50
50
|
f.check = true;
|
|
51
|
-
else if (a === "--write")
|
|
52
|
-
f.write = true;
|
|
53
51
|
else if (a === "--deno")
|
|
54
52
|
f.deno = true;
|
|
55
53
|
else if (a === "--no-models")
|
|
@@ -97,11 +95,12 @@ Usage:
|
|
|
97
95
|
urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
|
|
98
96
|
(--code-first is shorthand for --style code)
|
|
99
97
|
urban check validate the manifest
|
|
100
|
-
urban gen [--check] [--no-models] derive artifacts
|
|
98
|
+
urban gen [--check] [--no-models] derive artifacts + scaffold write-once handler stubs
|
|
99
|
+
(--check: verify up-to-date without writing; a missing stub or
|
|
100
|
+
drifted artifact fails — run \`urban gen\`)
|
|
101
101
|
(--no-models: skip writing derived .bpmn; type-contracts only)
|
|
102
102
|
urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
|
|
103
103
|
(--stdout: print {models,incomplete} JSON without writing)
|
|
104
|
-
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
105
104
|
urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
|
|
106
105
|
urban run materialize + serve the app
|
|
107
106
|
urban dev run + watch sources, hot-reload on change
|
|
@@ -136,7 +135,7 @@ async function cmdCheck(f) {
|
|
|
136
135
|
}
|
|
137
136
|
async function cmdGen(f) {
|
|
138
137
|
const io = createNodeGenIO();
|
|
139
|
-
const res = await
|
|
138
|
+
const res = await generate({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
|
|
140
139
|
if (res.incomplete) {
|
|
141
140
|
console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
|
|
142
141
|
for (const e of res.modelErrors)
|
|
@@ -144,18 +143,39 @@ async function cmdGen(f) {
|
|
|
144
143
|
return 1;
|
|
145
144
|
}
|
|
146
145
|
if (f.check) {
|
|
147
|
-
if (res.drift.length === 0) {
|
|
148
|
-
console.log(`✔ generated artifacts are up to date (${res.artifacts.length} checked)`);
|
|
146
|
+
if (res.drift.length === 0 && res.missingStubs.length === 0) {
|
|
147
|
+
console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
|
|
149
148
|
return 0;
|
|
150
149
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
if (res.drift.length > 0) {
|
|
151
|
+
console.error(`✖ ${res.drift.length} generated artifact(s) are out of date — run \`urban gen\`:`);
|
|
152
|
+
for (const p of res.drift)
|
|
153
|
+
console.error(` • ${p}`);
|
|
154
|
+
}
|
|
155
|
+
if (res.missingStubs.length > 0) {
|
|
156
|
+
console.error(`✖ ${res.missingStubs.length} handler stub(s) missing — run \`urban gen\`:`);
|
|
157
|
+
for (const p of res.missingStubs)
|
|
158
|
+
console.error(` • ${p}`);
|
|
159
|
+
}
|
|
154
160
|
return 1;
|
|
155
161
|
}
|
|
156
162
|
console.log(`✔ generated ${res.artifacts.length} artifact(s):`);
|
|
157
163
|
for (const a of res.artifacts)
|
|
158
164
|
console.log(` • ${a.path}`);
|
|
165
|
+
// Write-once handler stubs scaffolded as part of generation (workers from the model, operation
|
|
166
|
+
// delegates from the OpenAPI spec). Existing, human-owned stubs are kept verbatim.
|
|
167
|
+
const created = [
|
|
168
|
+
...res.workerStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
|
|
169
|
+
...res.operationStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
|
|
170
|
+
];
|
|
171
|
+
if (created.length > 0) {
|
|
172
|
+
console.log(`✔ scaffolded ${created.length} handler stub(s):`);
|
|
173
|
+
for (const p of created)
|
|
174
|
+
console.log(` + ${p}`);
|
|
175
|
+
}
|
|
176
|
+
if (res.manifestPatched) {
|
|
177
|
+
console.log(`✔ wired ${res.wiredWorkers.length} worker(s) into ${f.manifest}`);
|
|
178
|
+
}
|
|
159
179
|
return 0;
|
|
160
180
|
}
|
|
161
181
|
async function cmdDerive(f) {
|
|
@@ -204,39 +224,6 @@ async function cmdDerive(f) {
|
|
|
204
224
|
console.log(` • ${a.path}`);
|
|
205
225
|
return 0;
|
|
206
226
|
}
|
|
207
|
-
async function cmdStubs(f) {
|
|
208
|
-
const io = createNodeGenIO();
|
|
209
|
-
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
210
|
-
const created = run.outcomes.filter((o) => o.status === "created");
|
|
211
|
-
const would = run.outcomes.filter((o) => o.status === "would-create");
|
|
212
|
-
const kept = run.outcomes.filter((o) => o.status === "kept");
|
|
213
|
-
const typing = (o) => o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
|
|
214
|
-
if (run.write) {
|
|
215
|
-
console.log(`✔ scaffolded ${created.length} worker stub(s):`);
|
|
216
|
-
for (const o of created)
|
|
217
|
-
console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
218
|
-
if (run.manifestPatched) {
|
|
219
|
-
console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
if (would.length === 0) {
|
|
224
|
-
console.log(`✔ no new worker stubs to scaffold`);
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
|
|
228
|
-
for (const o of would)
|
|
229
|
-
console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
for (const o of kept)
|
|
233
|
-
console.log(` = ${o.handlerPath} (kept — already exists)`);
|
|
234
|
-
if (run.skipped.length > 0) {
|
|
235
|
-
console.log(` skipped ${run.skipped.length}: ` +
|
|
236
|
-
run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
|
|
237
|
-
}
|
|
238
|
-
return 0;
|
|
239
|
-
}
|
|
240
227
|
async function cmdRun(f, mount) {
|
|
241
228
|
const app = await runFromEnv({
|
|
242
229
|
root: f.root,
|
|
@@ -417,8 +404,6 @@ export async function main(argv) {
|
|
|
417
404
|
return cmdGen(f);
|
|
418
405
|
case "derive":
|
|
419
406
|
return cmdDerive(f);
|
|
420
|
-
case "stubs":
|
|
421
|
-
return cmdStubs(f);
|
|
422
407
|
case "add":
|
|
423
408
|
return cmdAdd(f);
|
|
424
409
|
case "data":
|
package/dist/openapi/spec.d.ts
CHANGED
|
@@ -50,6 +50,26 @@ export interface OpenApiResponse {
|
|
|
50
50
|
description?: string;
|
|
51
51
|
content?: Record<string, OpenApiMediaType>;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A security scheme (OpenAPI Components Object → `securitySchemes`). The supported profile is the
|
|
55
|
+
* `apiKey` type carried in a request header (ADR 0059): a webhook/operation is guarded by a shared
|
|
56
|
+
* secret. The expected value is never inlined — it is read from an environment variable named by
|
|
57
|
+
* the `x-nano-secret-env` extension (ADR 0025/0027: secrets stay env pointers).
|
|
58
|
+
*/
|
|
59
|
+
export interface OpenApiSecurityScheme {
|
|
60
|
+
type?: string;
|
|
61
|
+
/** For `apiKey`: where the key travels. `header` and `query` are enforced in this slice. */
|
|
62
|
+
in?: "header" | "query" | "cookie";
|
|
63
|
+
/** For `apiKey`: the header (or query) name carrying the key, e.g. `X-Webhook-Key`. */
|
|
64
|
+
name?: string;
|
|
65
|
+
/** Env var holding the expected secret value. Required to enforce an `apiKey` scheme. */
|
|
66
|
+
"x-nano-secret-env"?: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
}
|
|
69
|
+
/** A security requirement: scheme name → scopes (scopes are unused for `apiKey`, always `[]`). An
|
|
70
|
+
* operation is authorized when ANY one requirement object is fully satisfied (OR across the list,
|
|
71
|
+
* AND within an object). An empty list (`security: []`) means the operation is explicitly open. */
|
|
72
|
+
export type OpenApiSecurityRequirement = Record<string, string[]>;
|
|
53
73
|
export interface OpenApiDoc {
|
|
54
74
|
openapi?: string;
|
|
55
75
|
info?: {
|
|
@@ -59,7 +79,10 @@ export interface OpenApiDoc {
|
|
|
59
79
|
paths?: Record<string, Record<string, unknown>>;
|
|
60
80
|
components?: {
|
|
61
81
|
schemas?: Record<string, OpenApiSchema>;
|
|
82
|
+
securitySchemes?: Record<string, OpenApiSecurityScheme>;
|
|
62
83
|
};
|
|
84
|
+
/** Document-level default security, applied to any operation that declares none. */
|
|
85
|
+
security?: OpenApiSecurityRequirement[];
|
|
63
86
|
}
|
|
64
87
|
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
65
88
|
export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
@@ -76,6 +99,9 @@ export interface OperationInfo {
|
|
|
76
99
|
requestBodyRequired: boolean;
|
|
77
100
|
/** Schema of the first 2xx (else default) JSON response, when declared. */
|
|
78
101
|
responseSchema?: OpenApiSchema;
|
|
102
|
+
/** Effective security requirements (op-level if declared — including an explicit empty list —
|
|
103
|
+
* else the document-level default, else `[]`). An empty list means the operation is open. */
|
|
104
|
+
security: OpenApiSecurityRequirement[];
|
|
79
105
|
eject: boolean;
|
|
80
106
|
summary?: string;
|
|
81
107
|
}
|
|
@@ -92,6 +118,15 @@ export declare function resolveSchema(doc: OpenApiDoc, schema: OpenApiSchema | u
|
|
|
92
118
|
* (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
|
|
93
119
|
* them at `warn` and skips them too — this keeps the pure collector total. */
|
|
94
120
|
export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
|
|
121
|
+
/**
|
|
122
|
+
* Throw if the document declares the same `operationId` on more than one operation. An operationId
|
|
123
|
+
* must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
|
|
124
|
+
* AND the generated controller registry key), so a duplicate would silently overwrite an earlier
|
|
125
|
+
* entry in the emitted object literal — dispatching the wrong delegate while still type-checking — and
|
|
126
|
+
* make the scaffolder plan the same stub twice. Called by the gen/scaffold path so `urban gen`
|
|
127
|
+
* fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
|
|
128
|
+
*/
|
|
129
|
+
export declare function assertUniqueOperationIds(doc: OpenApiDoc): void;
|
|
95
130
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
96
131
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
97
132
|
export declare function operationsWithoutId(doc: OpenApiDoc): string[];
|
|
@@ -109,6 +144,34 @@ export declare function isSafeOperationId(id: string): boolean;
|
|
|
109
144
|
* path segment. collectOperations skips these (they never mount or drive an import); the runtime
|
|
110
145
|
* surfaces them at `warn`. */
|
|
111
146
|
export declare function operationsWithUnsafeId(doc: OpenApiDoc): string[];
|
|
147
|
+
/** The outcome of enforcing an operation's security: `ok` when authorized, otherwise the HTTP
|
|
148
|
+
* status to return — `401` (the request presented no/invalid credential) or `500` (the app is
|
|
149
|
+
* misconfigured: an operation references an unknown scheme, an unsupported scheme type, or a
|
|
150
|
+
* scheme whose secret env var is unset). */
|
|
151
|
+
export interface SecurityDecision {
|
|
152
|
+
ok: boolean;
|
|
153
|
+
status?: 401 | 500;
|
|
154
|
+
error?: string;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Enforce an operation's effective security requirements (ADR 0059). Pure: the caller supplies the
|
|
158
|
+
* request's header/query readers and an env reader, so all policy lives here (and is unit-tested)
|
|
159
|
+
* while the impure lookups stay in the runtime.
|
|
160
|
+
*
|
|
161
|
+
* Semantics follow OpenAPI: authorized when ANY one requirement object is satisfied (OR), and a
|
|
162
|
+
* requirement object is satisfied only when ALL its schemes are (AND). An empty requirement list
|
|
163
|
+
* means the operation is open. Only `apiKey` schemes (in `header` or `query`) are enforced; the
|
|
164
|
+
* expected value comes from the scheme's `x-nano-secret-env` env var.
|
|
165
|
+
*
|
|
166
|
+
* A referenced scheme that is unknown, not `apiKey`, or missing its secret env var is a server
|
|
167
|
+
* misconfiguration → `500` (fail closed), reported in preference to a `401` so operators see the
|
|
168
|
+
* real cause. A well-formed scheme with a wrong/absent presented credential → `401`.
|
|
169
|
+
*/
|
|
170
|
+
export declare function evaluateSecurity(doc: OpenApiDoc, op: OperationInfo, getHeader: (name: string) => string | undefined, getQuery: (name: string) => string | undefined, getSecret: (envVar: string) => string | undefined): SecurityDecision;
|
|
171
|
+
/** "scheme (op)" pointers for every operation security requirement that names a scheme absent from
|
|
172
|
+
* components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
|
|
173
|
+
* `--check` gate can surface ahead of time. */
|
|
174
|
+
export declare function undeclaredSecuritySchemes(doc: OpenApiDoc): string[];
|
|
112
175
|
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
113
176
|
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
114
177
|
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
package/dist/openapi/spec.js
CHANGED
|
@@ -126,6 +126,12 @@ export function collectOperations(doc) {
|
|
|
126
126
|
const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
|
|
127
127
|
const requestBodySchema = firstJsonSchema(isRecord(requestBody?.content) ? requestBody.content : undefined);
|
|
128
128
|
const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
|
|
129
|
+
// Effective security: an operation's own `security` (even an explicit `[]`, which opts out of
|
|
130
|
+
// a document-level default) wins; otherwise inherit the document-level default; otherwise open.
|
|
131
|
+
const rawSecurity = Object.prototype.hasOwnProperty.call(opRaw, "security")
|
|
132
|
+
? opRaw.security
|
|
133
|
+
: doc.security;
|
|
134
|
+
const security = normalizeSecurity(rawSecurity);
|
|
129
135
|
out.push({
|
|
130
136
|
operationId,
|
|
131
137
|
method,
|
|
@@ -134,6 +140,7 @@ export function collectOperations(doc) {
|
|
|
134
140
|
requestBodySchema,
|
|
135
141
|
requestBodyRequired: requestBody?.required === true,
|
|
136
142
|
responseSchema: firstSuccessSchema(responses),
|
|
143
|
+
security,
|
|
137
144
|
eject: opRaw["x-urban-eject"] === true,
|
|
138
145
|
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
139
146
|
});
|
|
@@ -141,6 +148,28 @@ export function collectOperations(doc) {
|
|
|
141
148
|
}
|
|
142
149
|
return out;
|
|
143
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Throw if the document declares the same `operationId` on more than one operation. An operationId
|
|
153
|
+
* must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
|
|
154
|
+
* AND the generated controller registry key), so a duplicate would silently overwrite an earlier
|
|
155
|
+
* entry in the emitted object literal — dispatching the wrong delegate while still type-checking — and
|
|
156
|
+
* make the scaffolder plan the same stub twice. Called by the gen/scaffold path so `urban gen`
|
|
157
|
+
* fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
|
|
158
|
+
*/
|
|
159
|
+
export function assertUniqueOperationIds(doc) {
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const duplicates = new Set();
|
|
162
|
+
for (const op of collectOperations(doc)) {
|
|
163
|
+
if (seen.has(op.operationId))
|
|
164
|
+
duplicates.add(op.operationId);
|
|
165
|
+
seen.add(op.operationId);
|
|
166
|
+
}
|
|
167
|
+
if (duplicates.size > 0) {
|
|
168
|
+
const list = [...duplicates].sort().join(", ");
|
|
169
|
+
throw new Error(`OpenAPI spec declares duplicate operationId(s): ${list}. Each operationId must be unique — ` +
|
|
170
|
+
"it names the delegate module and the generated controller registry key.");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
144
173
|
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
145
174
|
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
146
175
|
export function operationsWithoutId(doc) {
|
|
@@ -205,6 +234,145 @@ export function operationsWithUnsafeId(doc) {
|
|
|
205
234
|
}
|
|
206
235
|
return out;
|
|
207
236
|
}
|
|
237
|
+
const MALFORMED_SECURITY_REQUIREMENT = "__nano_malformed_security_requirement__";
|
|
238
|
+
function malformedSecurityRequirement() {
|
|
239
|
+
return { [MALFORMED_SECURITY_REQUIREMENT]: [] };
|
|
240
|
+
}
|
|
241
|
+
/** Normalize a raw `security` value into a clean requirement list. Missing → `[]` (open).
|
|
242
|
+
* Present-but-malformed values become an impossible marker requirement so enforcement fails closed
|
|
243
|
+
* as a 500 misconfiguration instead of silently opening a guarded operation. */
|
|
244
|
+
function normalizeSecurity(raw) {
|
|
245
|
+
if (raw === undefined)
|
|
246
|
+
return [];
|
|
247
|
+
if (!Array.isArray(raw))
|
|
248
|
+
return [malformedSecurityRequirement()];
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const entry of raw) {
|
|
251
|
+
if (!isRecord(entry)) {
|
|
252
|
+
out.push(malformedSecurityRequirement());
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const req = {};
|
|
256
|
+
let malformed = false;
|
|
257
|
+
for (const [name, scopes] of Object.entries(entry)) {
|
|
258
|
+
if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) {
|
|
259
|
+
malformed = true;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
req[name] = scopes;
|
|
263
|
+
}
|
|
264
|
+
out.push(malformed ? malformedSecurityRequirement() : req);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
const SECURITY_OK = { ok: true };
|
|
269
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
270
|
+
/** Constant-time string comparison — avoids leaking how many leading characters of a secret matched
|
|
271
|
+
* via response timing. Unequal lengths short-circuit (an acceptable length oracle for API keys). */
|
|
272
|
+
function timingSafeEqual(a, b) {
|
|
273
|
+
const ab = TEXT_ENCODER.encode(a);
|
|
274
|
+
const bb = TEXT_ENCODER.encode(b);
|
|
275
|
+
if (ab.length !== bb.length)
|
|
276
|
+
return false;
|
|
277
|
+
let diff = 0;
|
|
278
|
+
for (let i = 0; i < ab.length; i++)
|
|
279
|
+
diff |= ab[i] ^ bb[i];
|
|
280
|
+
return diff === 0;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Enforce an operation's effective security requirements (ADR 0059). Pure: the caller supplies the
|
|
284
|
+
* request's header/query readers and an env reader, so all policy lives here (and is unit-tested)
|
|
285
|
+
* while the impure lookups stay in the runtime.
|
|
286
|
+
*
|
|
287
|
+
* Semantics follow OpenAPI: authorized when ANY one requirement object is satisfied (OR), and a
|
|
288
|
+
* requirement object is satisfied only when ALL its schemes are (AND). An empty requirement list
|
|
289
|
+
* means the operation is open. Only `apiKey` schemes (in `header` or `query`) are enforced; the
|
|
290
|
+
* expected value comes from the scheme's `x-nano-secret-env` env var.
|
|
291
|
+
*
|
|
292
|
+
* A referenced scheme that is unknown, not `apiKey`, or missing its secret env var is a server
|
|
293
|
+
* misconfiguration → `500` (fail closed), reported in preference to a `401` so operators see the
|
|
294
|
+
* real cause. A well-formed scheme with a wrong/absent presented credential → `401`.
|
|
295
|
+
*/
|
|
296
|
+
export function evaluateSecurity(doc, op, getHeader, getQuery, getSecret) {
|
|
297
|
+
if (op.security.length === 0)
|
|
298
|
+
return SECURITY_OK;
|
|
299
|
+
const schemes = doc.components?.securitySchemes ?? {};
|
|
300
|
+
let misconfig;
|
|
301
|
+
for (const requirement of op.security) {
|
|
302
|
+
const names = Object.keys(requirement);
|
|
303
|
+
if (names.length === 0)
|
|
304
|
+
return SECURITY_OK; // an empty object is a "no auth" alternative
|
|
305
|
+
let satisfied = true;
|
|
306
|
+
for (const name of names) {
|
|
307
|
+
if (name === MALFORMED_SECURITY_REQUIREMENT) {
|
|
308
|
+
misconfig ??= "malformed security requirement";
|
|
309
|
+
satisfied = false;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
const scheme = schemes[name];
|
|
313
|
+
if (!scheme) {
|
|
314
|
+
misconfig ??= `security scheme "${name}" is not declared in components.securitySchemes`;
|
|
315
|
+
satisfied = false;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (scheme.type !== "apiKey") {
|
|
319
|
+
misconfig ??= `security scheme "${name}" has unsupported type "${scheme.type ?? "(none)"}" (only apiKey is enforced)`;
|
|
320
|
+
satisfied = false;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
const where = scheme.in ?? "header";
|
|
324
|
+
if (where !== "header" && where !== "query") {
|
|
325
|
+
misconfig ??= `security scheme "${name}" apiKey location "${where}" is unsupported (use header or query)`;
|
|
326
|
+
satisfied = false;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
if (!scheme.name) {
|
|
330
|
+
misconfig ??= `security scheme "${name}" is missing its apiKey \`name\``;
|
|
331
|
+
satisfied = false;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
const envVar = scheme["x-nano-secret-env"];
|
|
335
|
+
if (!envVar) {
|
|
336
|
+
misconfig ??= `security scheme "${name}" is missing \`x-nano-secret-env\` (the env var holding the expected key)`;
|
|
337
|
+
satisfied = false;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
const expected = getSecret(envVar);
|
|
341
|
+
if (expected === undefined || expected === "") {
|
|
342
|
+
misconfig ??= `security scheme "${name}" secret env ${envVar} is not set`;
|
|
343
|
+
satisfied = false;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
const presented = where === "header" ? getHeader(scheme.name) : getQuery(scheme.name);
|
|
347
|
+
if (presented === undefined || !timingSafeEqual(presented, expected)) {
|
|
348
|
+
satisfied = false; // a wrong/absent credential → 401 (not a misconfig)
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (satisfied)
|
|
353
|
+
return SECURITY_OK;
|
|
354
|
+
}
|
|
355
|
+
return misconfig
|
|
356
|
+
? { ok: false, status: 500, error: `security misconfigured: ${misconfig}` }
|
|
357
|
+
: { ok: false, status: 401, error: "unauthorized" };
|
|
358
|
+
}
|
|
359
|
+
/** "scheme (op)" pointers for every operation security requirement that names a scheme absent from
|
|
360
|
+
* components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
|
|
361
|
+
* `--check` gate can surface ahead of time. */
|
|
362
|
+
export function undeclaredSecuritySchemes(doc) {
|
|
363
|
+
const declared = new Set(Object.keys(doc.components?.securitySchemes ?? {}));
|
|
364
|
+
const out = [];
|
|
365
|
+
for (const op of collectOperations(doc)) {
|
|
366
|
+
for (const requirement of op.security) {
|
|
367
|
+
for (const name of Object.keys(requirement)) {
|
|
368
|
+
if (name !== MALFORMED_SECURITY_REQUIREMENT && !declared.has(name)) {
|
|
369
|
+
out.push(`${name} (${op.method.toUpperCase()} ${op.path})`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
208
376
|
function normalizeParameters(raw) {
|
|
209
377
|
// OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
|
|
210
378
|
// overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
|
|
@@ -13,17 +13,23 @@ export interface ApiBinding {
|
|
|
13
13
|
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
14
14
|
docs?: boolean | string;
|
|
15
15
|
}
|
|
16
|
-
/** The `{ params, query, body }` contract a typed operation is generic over.
|
|
16
|
+
/** The `{ params, query, body }` contract a typed operation is generic over. `body` is optional so
|
|
17
|
+
* a generated request type for an operation without a required requestBody (its `body?` is
|
|
18
|
+
* optional/`undefined`) still satisfies the constraint — see the generated `operations.ts` /
|
|
19
|
+
* `controller.ts`, which parameterise `OperationHandler` over the per-op request type. */
|
|
17
20
|
export interface OperationContract {
|
|
18
21
|
params: unknown;
|
|
19
22
|
query: unknown;
|
|
20
|
-
body
|
|
23
|
+
body?: unknown;
|
|
21
24
|
}
|
|
22
|
-
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body).
|
|
25
|
+
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). `body` is
|
|
26
|
+
* optional so a raw (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the
|
|
27
|
+
* generated registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body
|
|
28
|
+
* is optional/absent must be a subtype of the default contract (handler args are contravariant). */
|
|
23
29
|
export interface DefaultContract {
|
|
24
30
|
params: Record<string, string>;
|
|
25
31
|
query: Record<string, string | string[] | undefined>;
|
|
26
|
-
body
|
|
32
|
+
body?: unknown;
|
|
27
33
|
}
|
|
28
34
|
/** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
|
|
29
35
|
* validated) path params, query, and JSON body. */
|
|
@@ -41,6 +47,15 @@ export interface OperationResult<Res = unknown> {
|
|
|
41
47
|
}
|
|
42
48
|
/** An app-authored operation delegate: validated/typed input + the injected AppApi. */
|
|
43
49
|
export type OperationHandler<Req extends OperationContract = DefaultContract, Res = unknown> = (input: OperationInput<Req>, app: AppApi) => Promise<OperationResult<Res> | void> | OperationResult<Res> | void;
|
|
50
|
+
/**
|
|
51
|
+
* Thrown by a write-once operation stub to signal the endpoint exists but has no implementation
|
|
52
|
+
* yet; the dispatcher maps it to HTTP `501 Not Implemented` (ADR 0059). Delete the throw once you
|
|
53
|
+
* implement the handler body. A brand property makes the check robust across module realms.
|
|
54
|
+
*/
|
|
55
|
+
export declare class NotImplemented extends Error {
|
|
56
|
+
readonly urbanNotImplemented: true;
|
|
57
|
+
constructor(operationId: string);
|
|
58
|
+
}
|
|
44
59
|
/**
|
|
45
60
|
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
46
61
|
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// `x-urban-eject: true` OpenAPI vendor extension. The delegate is always additively imperative: it
|
|
13
13
|
// gets the raw `req` and full `AppApi` on top of the validated input.
|
|
14
14
|
import { errorMessage } from "../guards.js";
|
|
15
|
+
import { MODULE_EXTENSION_CANDIDATES } from "../module-path.js";
|
|
15
16
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
16
17
|
import { resolveAppPath } from "./datasource.js";
|
|
17
|
-
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
|
|
18
|
+
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
|
|
18
19
|
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
19
20
|
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
20
21
|
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
@@ -51,6 +52,23 @@ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger
|
|
|
51
52
|
</body>
|
|
52
53
|
</html>`;
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Thrown by a write-once operation stub to signal the endpoint exists but has no implementation
|
|
57
|
+
* yet; the dispatcher maps it to HTTP `501 Not Implemented` (ADR 0059). Delete the throw once you
|
|
58
|
+
* implement the handler body. A brand property makes the check robust across module realms.
|
|
59
|
+
*/
|
|
60
|
+
export class NotImplemented extends Error {
|
|
61
|
+
urbanNotImplemented = true;
|
|
62
|
+
constructor(operationId) {
|
|
63
|
+
super(`operation not implemented: ${operationId}`);
|
|
64
|
+
this.name = "NotImplemented";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True for a `NotImplemented` throw (instanceof, or the brand — resilient to realm/dup-copy). */
|
|
68
|
+
function isNotImplemented(e) {
|
|
69
|
+
return (e instanceof NotImplemented ||
|
|
70
|
+
(typeof e === "object" && e !== null && Reflect.get(e, "urbanNotImplemented") === true));
|
|
71
|
+
}
|
|
54
72
|
/**
|
|
55
73
|
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
56
74
|
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
@@ -64,6 +82,9 @@ export function defineOperation(_id, handler) {
|
|
|
64
82
|
function isFunction(v) {
|
|
65
83
|
return typeof v === "function";
|
|
66
84
|
}
|
|
85
|
+
function isRecord(v) {
|
|
86
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
87
|
+
}
|
|
67
88
|
/** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
|
|
68
89
|
export function resolveOperationHandler(mod) {
|
|
69
90
|
if (isFunction(mod.default))
|
|
@@ -190,7 +211,8 @@ export function mountApi(ctx, app) {
|
|
|
190
211
|
}
|
|
191
212
|
return docPromise;
|
|
192
213
|
};
|
|
193
|
-
// Lazily import + cache each delegate module, mirroring mountActions.
|
|
214
|
+
// Lazily import + cache each delegate module, mirroring mountActions. This is the back-compat
|
|
215
|
+
// fallback for apps without a generated controller registry (see loadController below).
|
|
194
216
|
const moduleCache = new Map();
|
|
195
217
|
const loadModule = (operationId) => {
|
|
196
218
|
// Defense-in-depth at the import sink: collectOperations already skips unsafe ids, but never
|
|
@@ -209,6 +231,43 @@ export function mountApi(ctx, app) {
|
|
|
209
231
|
}
|
|
210
232
|
return pending;
|
|
211
233
|
};
|
|
234
|
+
// Prefer the generated delegate registry (ADR 0059): one statically-typed barrel the toolkit
|
|
235
|
+
// emits from the spec, so a missing/mismatched/orphan delegate is a `tsc` error, and dispatch is
|
|
236
|
+
// a deterministic map lookup rather than a per-request path import. Imported ONCE and cached.
|
|
237
|
+
// Absent (an app that hasn't re-genned) → `null`, and dispatch falls back to `loadModule`. A
|
|
238
|
+
// present-but-broken controller (a delegate that throws at import) is NOT swallowed — it retries
|
|
239
|
+
// and surfaces as a 500 with the reason, exactly like a bad delegate does today.
|
|
240
|
+
let controllerPromise;
|
|
241
|
+
const loadController = () => {
|
|
242
|
+
if (!controllerPromise) {
|
|
243
|
+
const rel = "nano-generated/controller";
|
|
244
|
+
controllerPromise = (async () => {
|
|
245
|
+
// Probe every supported module extension (compiled .js first, mirroring resolveModulePath),
|
|
246
|
+
// NOT just `.ts`: a published / `urban run` app ships `controller.js` with the TS sources
|
|
247
|
+
// absent, and probing only `.ts` there would wrongly report the registry missing and fall
|
|
248
|
+
// back to per-op dynamic imports — defeating deterministic dispatch in production.
|
|
249
|
+
let present = false;
|
|
250
|
+
for (const ext of MODULE_EXTENSION_CANDIDATES) {
|
|
251
|
+
if (await ctx.host.exists(resolveAppPath(ctx.root, rel + ext))) {
|
|
252
|
+
present = true;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (!present)
|
|
257
|
+
return null;
|
|
258
|
+
const mod = await ctx.host.importModule(resolveAppPath(ctx.root, rel));
|
|
259
|
+
const ops = mod.operations;
|
|
260
|
+
if (!isRecord(ops)) {
|
|
261
|
+
throw new Error("nano-generated/controller must export an `operations` registry");
|
|
262
|
+
}
|
|
263
|
+
return ops;
|
|
264
|
+
})().catch((e) => {
|
|
265
|
+
controllerPromise = undefined; // don't cache the failure — let a later request retry
|
|
266
|
+
throw e;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return controllerPromise;
|
|
270
|
+
};
|
|
212
271
|
// Build the operation table lazily from the loaded doc (cached).
|
|
213
272
|
let opsPromise;
|
|
214
273
|
const loadOps = () => {
|
|
@@ -229,6 +288,10 @@ export function mountApi(ctx, app) {
|
|
|
229
288
|
if (unsafe.length > 0) {
|
|
230
289
|
ctx.host.log("warn", "OpenAPI operationIds that are not a safe path segment are skipped (ADR 0058)", { unsafe });
|
|
231
290
|
}
|
|
291
|
+
const undeclaredSchemes = undeclaredSecuritySchemes(d);
|
|
292
|
+
if (undeclaredSchemes.length > 0) {
|
|
293
|
+
ctx.host.log("warn", "OpenAPI operations require a security scheme not declared in components.securitySchemes — those requests are rejected with 500 (ADR 0059)", { undeclared: undeclaredSchemes });
|
|
294
|
+
}
|
|
232
295
|
const mounted = collectOperations(d).map((op) => {
|
|
233
296
|
const { pattern, paramNames } = toRouteMatcher(base, op.path);
|
|
234
297
|
return { op, pattern, paramNames };
|
|
@@ -270,6 +333,19 @@ export function mountApi(ctx, app) {
|
|
|
270
333
|
}
|
|
271
334
|
const { op } = match;
|
|
272
335
|
const ejected = surfaceEject || op.eject;
|
|
336
|
+
// Enforce the operation's security requirements (ADR 0059) before touching the body or the
|
|
337
|
+
// delegate: an unauthorized request should do no work and reveal nothing. Enforced even for
|
|
338
|
+
// ejected ops (eject changes body handling, not who may call the operation).
|
|
339
|
+
const authz = evaluateSecurity(d, op, (name) => req.headers.get(name) ?? undefined, (name) => req.query.get(name) ?? undefined, (envVar) => ctx.host.env(envVar));
|
|
340
|
+
if (!authz.ok) {
|
|
341
|
+
if (authz.status === 500) {
|
|
342
|
+
ctx.host.log("error", "OpenAPI operation security is misconfigured (ADR 0059)", {
|
|
343
|
+
operationId: op.operationId,
|
|
344
|
+
reason: authz.error,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return json({ error: authz.error ?? "unauthorized" }, authz.status ?? 401);
|
|
348
|
+
}
|
|
273
349
|
// Path params from the capture groups; query as a single-or-array map.
|
|
274
350
|
const captures = match.pattern.exec(req.path) ?? [];
|
|
275
351
|
const params = {};
|
|
@@ -358,7 +434,22 @@ export function mountApi(ctx, app) {
|
|
|
358
434
|
}
|
|
359
435
|
let handler;
|
|
360
436
|
try {
|
|
361
|
-
|
|
437
|
+
const controller = await loadController();
|
|
438
|
+
if (controller) {
|
|
439
|
+
// Deterministic dispatch: the generated registry guarantees (at `tsc` time) one delegate
|
|
440
|
+
// per operationId. A missing/non-function key here means the controller is stale vs the spec.
|
|
441
|
+
const candidate = controller[op.operationId];
|
|
442
|
+
if (!isFunction(candidate)) {
|
|
443
|
+
ctx.host.log("error", "operation not registered in the generated controller (run `urban gen`)", {
|
|
444
|
+
operationId: op.operationId,
|
|
445
|
+
});
|
|
446
|
+
return json({ error: `operation ${op.operationId} is not registered — run \`urban gen\`` }, 500);
|
|
447
|
+
}
|
|
448
|
+
handler = candidate;
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
handler = resolveOperationHandler(await loadModule(op.operationId));
|
|
452
|
+
}
|
|
362
453
|
}
|
|
363
454
|
catch (e) {
|
|
364
455
|
ctx.host.log("error", "operation delegate failed to load", {
|
|
@@ -401,10 +492,14 @@ export function mountApi(ctx, app) {
|
|
|
401
492
|
};
|
|
402
493
|
}
|
|
403
494
|
catch (e) {
|
|
495
|
+
// A write-once stub (or any delegate) can signal "endpoint exists, not implemented yet" with
|
|
496
|
+
// a NotImplemented throw → 501, distinct from an unexpected fault (500).
|
|
497
|
+
if (isNotImplemented(e)) {
|
|
498
|
+
return json({ error: errorMessage(e), operationId: op.operationId }, 501);
|
|
499
|
+
}
|
|
404
500
|
return json({ error: errorMessage(e) }, 500);
|
|
405
501
|
}
|
|
406
502
|
};
|
|
407
|
-
// Categorical safety net: any unexpected throw during dispatch — e.g. a malformed spec
|
|
408
503
|
// `pattern` surfacing from validation — becomes a controlled 500 rather than escaping into
|
|
409
504
|
// the host request handler, which has no top-level catch of its own.
|
|
410
505
|
const dispatch = async (req) => {
|