@nanobpm/urban 0.33.0 → 0.35.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/openapi/spec.d.ts +58 -2
- package/dist/openapi/spec.js +168 -7
- package/dist/runtime/core/modules/api.js +18 -1
- package/package.json +4 -3
- package/src/openapi/spec.test.ts +230 -3
- package/src/openapi/spec.ts +217 -11
- package/src/runtime/core/modules/api.test.ts +79 -3
- package/src/runtime/core/modules/api.ts +30 -0
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,11 +99,16 @@ 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
|
}
|
|
82
|
-
/** Parse an OpenAPI document from text.
|
|
83
|
-
*
|
|
108
|
+
/** Parse an OpenAPI document from text. Accepts both YAML (the sanctioned authoring format for
|
|
109
|
+
* the HTTP surface — ADR 0059) and JSON (still accepted, e.g. a generated interchange artifact).
|
|
110
|
+
* JSON is a strict subset of YAML 1.2, so JSON is tried first for a precise error + fast path,
|
|
111
|
+
* then the text is parsed as YAML. Throws with a clear message on malformed input. */
|
|
84
112
|
export declare function parseSpec(text: string): OpenApiDoc;
|
|
85
113
|
/** The basename of a local `#/components/schemas/Name` ref, else undefined. */
|
|
86
114
|
export declare function refName(ref: string): string | undefined;
|
|
@@ -107,6 +135,34 @@ export declare function isSafeOperationId(id: string): boolean;
|
|
|
107
135
|
* path segment. collectOperations skips these (they never mount or drive an import); the runtime
|
|
108
136
|
* surfaces them at `warn`. */
|
|
109
137
|
export declare function operationsWithUnsafeId(doc: OpenApiDoc): string[];
|
|
138
|
+
/** The outcome of enforcing an operation's security: `ok` when authorized, otherwise the HTTP
|
|
139
|
+
* status to return — `401` (the request presented no/invalid credential) or `500` (the app is
|
|
140
|
+
* misconfigured: an operation references an unknown scheme, an unsupported scheme type, or a
|
|
141
|
+
* scheme whose secret env var is unset). */
|
|
142
|
+
export interface SecurityDecision {
|
|
143
|
+
ok: boolean;
|
|
144
|
+
status?: 401 | 500;
|
|
145
|
+
error?: string;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Enforce an operation's effective security requirements (ADR 0059). Pure: the caller supplies the
|
|
149
|
+
* request's header/query readers and an env reader, so all policy lives here (and is unit-tested)
|
|
150
|
+
* while the impure lookups stay in the runtime.
|
|
151
|
+
*
|
|
152
|
+
* Semantics follow OpenAPI: authorized when ANY one requirement object is satisfied (OR), and a
|
|
153
|
+
* requirement object is satisfied only when ALL its schemes are (AND). An empty requirement list
|
|
154
|
+
* means the operation is open. Only `apiKey` schemes (in `header` or `query`) are enforced; the
|
|
155
|
+
* expected value comes from the scheme's `x-nano-secret-env` env var.
|
|
156
|
+
*
|
|
157
|
+
* A referenced scheme that is unknown, not `apiKey`, or missing its secret env var is a server
|
|
158
|
+
* misconfiguration → `500` (fail closed), reported in preference to a `401` so operators see the
|
|
159
|
+
* real cause. A well-formed scheme with a wrong/absent presented credential → `401`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function evaluateSecurity(doc: OpenApiDoc, op: OperationInfo, getHeader: (name: string) => string | undefined, getQuery: (name: string) => string | undefined, getSecret: (envVar: string) => string | undefined): SecurityDecision;
|
|
162
|
+
/** "scheme (op)" pointers for every operation security requirement that names a scheme absent from
|
|
163
|
+
* components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
|
|
164
|
+
* `--check` gate can surface ahead of time. */
|
|
165
|
+
export declare function undeclaredSecuritySchemes(doc: OpenApiDoc): string[];
|
|
110
166
|
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
111
167
|
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
112
168
|
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
package/dist/openapi/spec.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// deriver (`toolkit/derivers/api.ts`, authoring-time type + wrapper emission) and the runtime
|
|
3
3
|
// (`runtime/core/modules/api.ts`, request validation + routing) build on this one module, so the
|
|
4
4
|
// derived types and the runtime routes/validation always agree. No IO, no `node:*`, no `Deno` —
|
|
5
|
-
// it operates on
|
|
5
|
+
// it operates on document text/objects. Its one external dependency is `yaml` (a browser-safe,
|
|
6
|
+
// pure-JS parser with a dedicated browser build) so `parseSpec` accepts YAML — the sanctioned
|
|
7
|
+
// authoring format for the HTTP surface (ADR 0059) — as well as JSON.
|
|
6
8
|
//
|
|
7
9
|
// Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
|
|
8
10
|
// requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
|
|
9
11
|
// string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
|
|
10
12
|
// XML, discriminated oneOf composition) is intentionally out of scope for this slice.
|
|
13
|
+
import { parse as parseYaml } from "yaml";
|
|
11
14
|
/**
|
|
12
15
|
* Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
|
|
13
16
|
* keyword be omitted when `properties`/`required`/`additionalProperties` already imply
|
|
@@ -33,18 +36,30 @@ function isRecord(v) {
|
|
|
33
36
|
function isSchema(v) {
|
|
34
37
|
return isRecord(v);
|
|
35
38
|
}
|
|
36
|
-
/** Parse an OpenAPI document from text.
|
|
37
|
-
*
|
|
39
|
+
/** Parse an OpenAPI document from text. Accepts both YAML (the sanctioned authoring format for
|
|
40
|
+
* the HTTP surface — ADR 0059) and JSON (still accepted, e.g. a generated interchange artifact).
|
|
41
|
+
* JSON is a strict subset of YAML 1.2, so JSON is tried first for a precise error + fast path,
|
|
42
|
+
* then the text is parsed as YAML. Throws with a clear message on malformed input. */
|
|
38
43
|
export function parseSpec(text) {
|
|
39
44
|
let doc;
|
|
40
45
|
try {
|
|
46
|
+
// Fast path + precise diagnostics for JSON (and the generated `openapi.json` interchange file).
|
|
41
47
|
doc = JSON.parse(text);
|
|
42
48
|
}
|
|
43
|
-
catch (
|
|
44
|
-
|
|
49
|
+
catch (jsonError) {
|
|
50
|
+
// Not JSON — parse as YAML (which also subsumes JSON, so authored `.yaml`/`.yml` specs load).
|
|
51
|
+
try {
|
|
52
|
+
doc = parseYaml(text);
|
|
53
|
+
}
|
|
54
|
+
catch (yamlError) {
|
|
55
|
+
const jsonMessage = jsonError instanceof Error ? jsonError.message : String(jsonError);
|
|
56
|
+
const yamlMessage = yamlError instanceof Error ? yamlError.message : String(yamlError);
|
|
57
|
+
throw new Error(`OpenAPI spec is not valid YAML or JSON: JSON parse error: ${jsonMessage}; YAML parse error: ${yamlMessage}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!isRecord(doc)) {
|
|
61
|
+
throw new Error("OpenAPI spec must be an object (a mapping at the document root)");
|
|
45
62
|
}
|
|
46
|
-
if (!isRecord(doc))
|
|
47
|
-
throw new Error("OpenAPI spec must be a JSON object");
|
|
48
63
|
return doc;
|
|
49
64
|
}
|
|
50
65
|
/** The basename of a local `#/components/schemas/Name` ref, else undefined. */
|
|
@@ -111,6 +126,12 @@ export function collectOperations(doc) {
|
|
|
111
126
|
const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
|
|
112
127
|
const requestBodySchema = firstJsonSchema(isRecord(requestBody?.content) ? requestBody.content : undefined);
|
|
113
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);
|
|
114
135
|
out.push({
|
|
115
136
|
operationId,
|
|
116
137
|
method,
|
|
@@ -119,6 +140,7 @@ export function collectOperations(doc) {
|
|
|
119
140
|
requestBodySchema,
|
|
120
141
|
requestBodyRequired: requestBody?.required === true,
|
|
121
142
|
responseSchema: firstSuccessSchema(responses),
|
|
143
|
+
security,
|
|
122
144
|
eject: opRaw["x-urban-eject"] === true,
|
|
123
145
|
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
124
146
|
});
|
|
@@ -190,6 +212,145 @@ export function operationsWithUnsafeId(doc) {
|
|
|
190
212
|
}
|
|
191
213
|
return out;
|
|
192
214
|
}
|
|
215
|
+
const MALFORMED_SECURITY_REQUIREMENT = "__nano_malformed_security_requirement__";
|
|
216
|
+
function malformedSecurityRequirement() {
|
|
217
|
+
return { [MALFORMED_SECURITY_REQUIREMENT]: [] };
|
|
218
|
+
}
|
|
219
|
+
/** Normalize a raw `security` value into a clean requirement list. Missing → `[]` (open).
|
|
220
|
+
* Present-but-malformed values become an impossible marker requirement so enforcement fails closed
|
|
221
|
+
* as a 500 misconfiguration instead of silently opening a guarded operation. */
|
|
222
|
+
function normalizeSecurity(raw) {
|
|
223
|
+
if (raw === undefined)
|
|
224
|
+
return [];
|
|
225
|
+
if (!Array.isArray(raw))
|
|
226
|
+
return [malformedSecurityRequirement()];
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const entry of raw) {
|
|
229
|
+
if (!isRecord(entry)) {
|
|
230
|
+
out.push(malformedSecurityRequirement());
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const req = {};
|
|
234
|
+
let malformed = false;
|
|
235
|
+
for (const [name, scopes] of Object.entries(entry)) {
|
|
236
|
+
if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) {
|
|
237
|
+
malformed = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
req[name] = scopes;
|
|
241
|
+
}
|
|
242
|
+
out.push(malformed ? malformedSecurityRequirement() : req);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
const SECURITY_OK = { ok: true };
|
|
247
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
248
|
+
/** Constant-time string comparison — avoids leaking how many leading characters of a secret matched
|
|
249
|
+
* via response timing. Unequal lengths short-circuit (an acceptable length oracle for API keys). */
|
|
250
|
+
function timingSafeEqual(a, b) {
|
|
251
|
+
const ab = TEXT_ENCODER.encode(a);
|
|
252
|
+
const bb = TEXT_ENCODER.encode(b);
|
|
253
|
+
if (ab.length !== bb.length)
|
|
254
|
+
return false;
|
|
255
|
+
let diff = 0;
|
|
256
|
+
for (let i = 0; i < ab.length; i++)
|
|
257
|
+
diff |= ab[i] ^ bb[i];
|
|
258
|
+
return diff === 0;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Enforce an operation's effective security requirements (ADR 0059). Pure: the caller supplies the
|
|
262
|
+
* request's header/query readers and an env reader, so all policy lives here (and is unit-tested)
|
|
263
|
+
* while the impure lookups stay in the runtime.
|
|
264
|
+
*
|
|
265
|
+
* Semantics follow OpenAPI: authorized when ANY one requirement object is satisfied (OR), and a
|
|
266
|
+
* requirement object is satisfied only when ALL its schemes are (AND). An empty requirement list
|
|
267
|
+
* means the operation is open. Only `apiKey` schemes (in `header` or `query`) are enforced; the
|
|
268
|
+
* expected value comes from the scheme's `x-nano-secret-env` env var.
|
|
269
|
+
*
|
|
270
|
+
* A referenced scheme that is unknown, not `apiKey`, or missing its secret env var is a server
|
|
271
|
+
* misconfiguration → `500` (fail closed), reported in preference to a `401` so operators see the
|
|
272
|
+
* real cause. A well-formed scheme with a wrong/absent presented credential → `401`.
|
|
273
|
+
*/
|
|
274
|
+
export function evaluateSecurity(doc, op, getHeader, getQuery, getSecret) {
|
|
275
|
+
if (op.security.length === 0)
|
|
276
|
+
return SECURITY_OK;
|
|
277
|
+
const schemes = doc.components?.securitySchemes ?? {};
|
|
278
|
+
let misconfig;
|
|
279
|
+
for (const requirement of op.security) {
|
|
280
|
+
const names = Object.keys(requirement);
|
|
281
|
+
if (names.length === 0)
|
|
282
|
+
return SECURITY_OK; // an empty object is a "no auth" alternative
|
|
283
|
+
let satisfied = true;
|
|
284
|
+
for (const name of names) {
|
|
285
|
+
if (name === MALFORMED_SECURITY_REQUIREMENT) {
|
|
286
|
+
misconfig ??= "malformed security requirement";
|
|
287
|
+
satisfied = false;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
const scheme = schemes[name];
|
|
291
|
+
if (!scheme) {
|
|
292
|
+
misconfig ??= `security scheme "${name}" is not declared in components.securitySchemes`;
|
|
293
|
+
satisfied = false;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
if (scheme.type !== "apiKey") {
|
|
297
|
+
misconfig ??= `security scheme "${name}" has unsupported type "${scheme.type ?? "(none)"}" (only apiKey is enforced)`;
|
|
298
|
+
satisfied = false;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
const where = scheme.in ?? "header";
|
|
302
|
+
if (where !== "header" && where !== "query") {
|
|
303
|
+
misconfig ??= `security scheme "${name}" apiKey location "${where}" is unsupported (use header or query)`;
|
|
304
|
+
satisfied = false;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
if (!scheme.name) {
|
|
308
|
+
misconfig ??= `security scheme "${name}" is missing its apiKey \`name\``;
|
|
309
|
+
satisfied = false;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
const envVar = scheme["x-nano-secret-env"];
|
|
313
|
+
if (!envVar) {
|
|
314
|
+
misconfig ??= `security scheme "${name}" is missing \`x-nano-secret-env\` (the env var holding the expected key)`;
|
|
315
|
+
satisfied = false;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
const expected = getSecret(envVar);
|
|
319
|
+
if (expected === undefined || expected === "") {
|
|
320
|
+
misconfig ??= `security scheme "${name}" secret env ${envVar} is not set`;
|
|
321
|
+
satisfied = false;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
const presented = where === "header" ? getHeader(scheme.name) : getQuery(scheme.name);
|
|
325
|
+
if (presented === undefined || !timingSafeEqual(presented, expected)) {
|
|
326
|
+
satisfied = false; // a wrong/absent credential → 401 (not a misconfig)
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (satisfied)
|
|
331
|
+
return SECURITY_OK;
|
|
332
|
+
}
|
|
333
|
+
return misconfig
|
|
334
|
+
? { ok: false, status: 500, error: `security misconfigured: ${misconfig}` }
|
|
335
|
+
: { ok: false, status: 401, error: "unauthorized" };
|
|
336
|
+
}
|
|
337
|
+
/** "scheme (op)" pointers for every operation security requirement that names a scheme absent from
|
|
338
|
+
* components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
|
|
339
|
+
* `--check` gate can surface ahead of time. */
|
|
340
|
+
export function undeclaredSecuritySchemes(doc) {
|
|
341
|
+
const declared = new Set(Object.keys(doc.components?.securitySchemes ?? {}));
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const op of collectOperations(doc)) {
|
|
344
|
+
for (const requirement of op.security) {
|
|
345
|
+
for (const name of Object.keys(requirement)) {
|
|
346
|
+
if (name !== MALFORMED_SECURITY_REQUIREMENT && !declared.has(name)) {
|
|
347
|
+
out.push(`${name} (${op.method.toUpperCase()} ${op.path})`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
193
354
|
function normalizeParameters(raw) {
|
|
194
355
|
// OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
|
|
195
356
|
// overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { errorMessage } from "../guards.js";
|
|
15
15
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
16
16
|
import { resolveAppPath } from "./datasource.js";
|
|
17
|
-
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
|
|
17
|
+
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
|
|
18
18
|
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
19
19
|
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
20
20
|
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
@@ -229,6 +229,10 @@ export function mountApi(ctx, app) {
|
|
|
229
229
|
if (unsafe.length > 0) {
|
|
230
230
|
ctx.host.log("warn", "OpenAPI operationIds that are not a safe path segment are skipped (ADR 0058)", { unsafe });
|
|
231
231
|
}
|
|
232
|
+
const undeclaredSchemes = undeclaredSecuritySchemes(d);
|
|
233
|
+
if (undeclaredSchemes.length > 0) {
|
|
234
|
+
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 });
|
|
235
|
+
}
|
|
232
236
|
const mounted = collectOperations(d).map((op) => {
|
|
233
237
|
const { pattern, paramNames } = toRouteMatcher(base, op.path);
|
|
234
238
|
return { op, pattern, paramNames };
|
|
@@ -270,6 +274,19 @@ export function mountApi(ctx, app) {
|
|
|
270
274
|
}
|
|
271
275
|
const { op } = match;
|
|
272
276
|
const ejected = surfaceEject || op.eject;
|
|
277
|
+
// Enforce the operation's security requirements (ADR 0059) before touching the body or the
|
|
278
|
+
// delegate: an unauthorized request should do no work and reveal nothing. Enforced even for
|
|
279
|
+
// ejected ops (eject changes body handling, not who may call the operation).
|
|
280
|
+
const authz = evaluateSecurity(d, op, (name) => req.headers.get(name) ?? undefined, (name) => req.query.get(name) ?? undefined, (envVar) => ctx.host.env(envVar));
|
|
281
|
+
if (!authz.ok) {
|
|
282
|
+
if (authz.status === 500) {
|
|
283
|
+
ctx.host.log("error", "OpenAPI operation security is misconfigured (ADR 0059)", {
|
|
284
|
+
operationId: op.operationId,
|
|
285
|
+
reason: authz.error,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return json({ error: authz.error ?? "unauthorized" }, authz.status ?? 401);
|
|
289
|
+
}
|
|
273
290
|
// Path params from the capture groups; query as a single-or-array map.
|
|
274
291
|
const captures = match.pattern.exec(req.path) ?? [];
|
|
275
292
|
const params = {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Urban: build and run code-first apps on Nano
|
|
3
|
+
"version": "0.35.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
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
@@ -66,7 +66,8 @@
|
|
|
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",
|
|
69
|
-
"create-urban-app": "^0.9.0"
|
|
69
|
+
"create-urban-app": "^0.9.0",
|
|
70
|
+
"yaml": "^2.9.0"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@types/node": "^22.20.1",
|
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
|
|
|
@@ -51,9 +53,65 @@ const doc: OpenApiDoc = {
|
|
|
51
53
|
},
|
|
52
54
|
};
|
|
53
55
|
|
|
54
|
-
test("parseSpec throws a clear error on non-
|
|
55
|
-
|
|
56
|
-
assert.throws(() => parseSpec("42"), /must be
|
|
56
|
+
test("parseSpec throws a clear error on non-object and unparseable input", () => {
|
|
57
|
+
// A bare scalar parses (as JSON or YAML) but is not a document object.
|
|
58
|
+
assert.throws(() => parseSpec("42"), /must be an object/);
|
|
59
|
+
// Unbalanced/garbage that is neither valid JSON nor valid YAML.
|
|
60
|
+
assert.throws(() => parseSpec("{ : ]["), /not valid YAML or JSON/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parseSpec includes both JSON and YAML diagnostics when neither parser accepts the input", () => {
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => parseSpec("{ : ]["),
|
|
66
|
+
/JSON parse error:.*YAML parse error:/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("parseSpec parses a YAML OpenAPI document (ADR 0059 authoring format)", () => {
|
|
71
|
+
const yaml = [
|
|
72
|
+
"openapi: 3.0.0",
|
|
73
|
+
"info:",
|
|
74
|
+
" title: Demo",
|
|
75
|
+
" version: 1.0.0",
|
|
76
|
+
"paths:",
|
|
77
|
+
" /invoices:",
|
|
78
|
+
" post:",
|
|
79
|
+
" operationId: createInvoice",
|
|
80
|
+
" requestBody:",
|
|
81
|
+
" required: true",
|
|
82
|
+
" content:",
|
|
83
|
+
" application/json:",
|
|
84
|
+
" schema:",
|
|
85
|
+
" $ref: '#/components/schemas/Invoice'",
|
|
86
|
+
" responses:",
|
|
87
|
+
" '201':",
|
|
88
|
+
" description: created",
|
|
89
|
+
"components:",
|
|
90
|
+
" schemas:",
|
|
91
|
+
" Invoice:",
|
|
92
|
+
" type: object",
|
|
93
|
+
" required: [id, amount]",
|
|
94
|
+
" properties:",
|
|
95
|
+
" id: { type: string }",
|
|
96
|
+
" amount: { type: integer, minimum: 1 }",
|
|
97
|
+
"",
|
|
98
|
+
].join("\n");
|
|
99
|
+
const parsed = parseSpec(yaml);
|
|
100
|
+
assert.equal(parsed.openapi, "3.0.0");
|
|
101
|
+
const ops = collectOperations(parsed);
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
ops.map((o) => o.operationId),
|
|
104
|
+
["createInvoice"],
|
|
105
|
+
);
|
|
106
|
+
// The $ref resolves against the YAML-authored components, same as a JSON spec.
|
|
107
|
+
const resolved = resolveSchema(parsed, { $ref: "#/components/schemas/Invoice" });
|
|
108
|
+
assert.equal(resolved?.type, "object");
|
|
109
|
+
assert.deepEqual(resolved?.required, ["id", "amount"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("parseSpec still parses JSON (accepted interchange format)", () => {
|
|
113
|
+
const parsed = parseSpec(JSON.stringify(doc));
|
|
114
|
+
assert.equal(parsed.openapi, "3.0.0");
|
|
57
115
|
});
|
|
58
116
|
|
|
59
117
|
test("collectOperations returns id-bearing ops in stable (path, method) order", () => {
|
|
@@ -290,3 +348,172 @@ test("validateValue handles null: typeless object rejects it; nullable/type-null
|
|
|
290
348
|
// A truly empty schema allows anything, including null.
|
|
291
349
|
assert.equal(validateValue(doc, {}, null).length, 0);
|
|
292
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
|
+
});
|
package/src/openapi/spec.ts
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
// deriver (`toolkit/derivers/api.ts`, authoring-time type + wrapper emission) and the runtime
|
|
3
3
|
// (`runtime/core/modules/api.ts`, request validation + routing) build on this one module, so the
|
|
4
4
|
// derived types and the runtime routes/validation always agree. No IO, no `node:*`, no `Deno` —
|
|
5
|
-
// it operates on
|
|
5
|
+
// it operates on document text/objects. Its one external dependency is `yaml` (a browser-safe,
|
|
6
|
+
// pure-JS parser with a dedicated browser build) so `parseSpec` accepts YAML — the sanctioned
|
|
7
|
+
// authoring format for the HTTP surface (ADR 0059) — as well as JSON.
|
|
6
8
|
//
|
|
7
9
|
// Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
|
|
8
10
|
// requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
|
|
9
11
|
// string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
|
|
10
12
|
// XML, discriminated oneOf composition) is intentionally out of scope for this slice.
|
|
11
13
|
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
|
|
12
16
|
/** A JSON Schema (the OpenAPI subset we read). Kept structural and permissive — unknown keywords
|
|
13
17
|
* are ignored by the validator and fall back to `unknown` in the type emitter. */
|
|
14
18
|
export interface OpenApiSchema {
|
|
@@ -80,11 +84,38 @@ export interface OpenApiResponse {
|
|
|
80
84
|
content?: Record<string, OpenApiMediaType>;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A security scheme (OpenAPI Components Object → `securitySchemes`). The supported profile is the
|
|
89
|
+
* `apiKey` type carried in a request header (ADR 0059): a webhook/operation is guarded by a shared
|
|
90
|
+
* secret. The expected value is never inlined — it is read from an environment variable named by
|
|
91
|
+
* the `x-nano-secret-env` extension (ADR 0025/0027: secrets stay env pointers).
|
|
92
|
+
*/
|
|
93
|
+
export interface OpenApiSecurityScheme {
|
|
94
|
+
type?: string;
|
|
95
|
+
/** For `apiKey`: where the key travels. `header` and `query` are enforced in this slice. */
|
|
96
|
+
in?: "header" | "query" | "cookie";
|
|
97
|
+
/** For `apiKey`: the header (or query) name carrying the key, e.g. `X-Webhook-Key`. */
|
|
98
|
+
name?: string;
|
|
99
|
+
/** Env var holding the expected secret value. Required to enforce an `apiKey` scheme. */
|
|
100
|
+
"x-nano-secret-env"?: string;
|
|
101
|
+
description?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A security requirement: scheme name → scopes (scopes are unused for `apiKey`, always `[]`). An
|
|
105
|
+
* operation is authorized when ANY one requirement object is fully satisfied (OR across the list,
|
|
106
|
+
* AND within an object). An empty list (`security: []`) means the operation is explicitly open. */
|
|
107
|
+
export type OpenApiSecurityRequirement = Record<string, string[]>;
|
|
108
|
+
|
|
83
109
|
export interface OpenApiDoc {
|
|
84
110
|
openapi?: string;
|
|
85
111
|
info?: { title?: string; version?: string };
|
|
86
112
|
paths?: Record<string, Record<string, unknown>>;
|
|
87
|
-
components?: {
|
|
113
|
+
components?: {
|
|
114
|
+
schemas?: Record<string, OpenApiSchema>;
|
|
115
|
+
securitySchemes?: Record<string, OpenApiSecurityScheme>;
|
|
116
|
+
};
|
|
117
|
+
/** Document-level default security, applied to any operation that declares none. */
|
|
118
|
+
security?: OpenApiSecurityRequirement[];
|
|
88
119
|
}
|
|
89
120
|
|
|
90
121
|
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
@@ -103,6 +134,9 @@ export interface OperationInfo {
|
|
|
103
134
|
requestBodyRequired: boolean;
|
|
104
135
|
/** Schema of the first 2xx (else default) JSON response, when declared. */
|
|
105
136
|
responseSchema?: OpenApiSchema;
|
|
137
|
+
/** Effective security requirements (op-level if declared — including an explicit empty list —
|
|
138
|
+
* else the document-level default, else `[]`). An empty list means the operation is open. */
|
|
139
|
+
security: OpenApiSecurityRequirement[];
|
|
106
140
|
eject: boolean;
|
|
107
141
|
summary?: string;
|
|
108
142
|
}
|
|
@@ -116,20 +150,28 @@ function isSchema(v: unknown): v is OpenApiSchema {
|
|
|
116
150
|
return isRecord(v);
|
|
117
151
|
}
|
|
118
152
|
|
|
119
|
-
/** Parse an OpenAPI document from text.
|
|
120
|
-
*
|
|
153
|
+
/** Parse an OpenAPI document from text. Accepts both YAML (the sanctioned authoring format for
|
|
154
|
+
* the HTTP surface — ADR 0059) and JSON (still accepted, e.g. a generated interchange artifact).
|
|
155
|
+
* JSON is a strict subset of YAML 1.2, so JSON is tried first for a precise error + fast path,
|
|
156
|
+
* then the text is parsed as YAML. Throws with a clear message on malformed input. */
|
|
121
157
|
export function parseSpec(text: string): OpenApiDoc {
|
|
122
158
|
let doc: unknown;
|
|
123
159
|
try {
|
|
160
|
+
// Fast path + precise diagnostics for JSON (and the generated `openapi.json` interchange file).
|
|
124
161
|
doc = JSON.parse(text);
|
|
125
|
-
} catch (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
} catch (jsonError) {
|
|
163
|
+
// Not JSON — parse as YAML (which also subsumes JSON, so authored `.yaml`/`.yml` specs load).
|
|
164
|
+
try {
|
|
165
|
+
doc = parseYaml(text);
|
|
166
|
+
} catch (yamlError) {
|
|
167
|
+
const jsonMessage = jsonError instanceof Error ? jsonError.message : String(jsonError);
|
|
168
|
+
const yamlMessage = yamlError instanceof Error ? yamlError.message : String(yamlError);
|
|
169
|
+
throw new Error(`OpenAPI spec is not valid YAML or JSON: JSON parse error: ${jsonMessage}; YAML parse error: ${yamlMessage}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!isRecord(doc)) {
|
|
173
|
+
throw new Error("OpenAPI spec must be an object (a mapping at the document root)");
|
|
131
174
|
}
|
|
132
|
-
if (!isRecord(doc)) throw new Error("OpenAPI spec must be a JSON object");
|
|
133
175
|
return doc;
|
|
134
176
|
}
|
|
135
177
|
|
|
@@ -200,6 +242,12 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
200
242
|
isRecord(requestBody?.content) ? requestBody.content : undefined,
|
|
201
243
|
);
|
|
202
244
|
const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
|
|
245
|
+
// Effective security: an operation's own `security` (even an explicit `[]`, which opts out of
|
|
246
|
+
// a document-level default) wins; otherwise inherit the document-level default; otherwise open.
|
|
247
|
+
const rawSecurity = Object.prototype.hasOwnProperty.call(opRaw, "security")
|
|
248
|
+
? opRaw.security
|
|
249
|
+
: doc.security;
|
|
250
|
+
const security = normalizeSecurity(rawSecurity);
|
|
203
251
|
out.push({
|
|
204
252
|
operationId,
|
|
205
253
|
method,
|
|
@@ -208,6 +256,7 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
208
256
|
requestBodySchema,
|
|
209
257
|
requestBodyRequired: requestBody?.required === true,
|
|
210
258
|
responseSchema: firstSuccessSchema(responses),
|
|
259
|
+
security,
|
|
211
260
|
eject: opRaw["x-urban-eject"] === true,
|
|
212
261
|
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
213
262
|
});
|
|
@@ -279,6 +328,163 @@ export function operationsWithUnsafeId(doc: OpenApiDoc): string[] {
|
|
|
279
328
|
return out;
|
|
280
329
|
}
|
|
281
330
|
|
|
331
|
+
const MALFORMED_SECURITY_REQUIREMENT = "__nano_malformed_security_requirement__";
|
|
332
|
+
|
|
333
|
+
function malformedSecurityRequirement(): OpenApiSecurityRequirement {
|
|
334
|
+
return { [MALFORMED_SECURITY_REQUIREMENT]: [] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Normalize a raw `security` value into a clean requirement list. Missing → `[]` (open).
|
|
338
|
+
* Present-but-malformed values become an impossible marker requirement so enforcement fails closed
|
|
339
|
+
* as a 500 misconfiguration instead of silently opening a guarded operation. */
|
|
340
|
+
function normalizeSecurity(raw: unknown): OpenApiSecurityRequirement[] {
|
|
341
|
+
if (raw === undefined) return [];
|
|
342
|
+
if (!Array.isArray(raw)) return [malformedSecurityRequirement()];
|
|
343
|
+
const out: OpenApiSecurityRequirement[] = [];
|
|
344
|
+
for (const entry of raw) {
|
|
345
|
+
if (!isRecord(entry)) {
|
|
346
|
+
out.push(malformedSecurityRequirement());
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const req: OpenApiSecurityRequirement = {};
|
|
350
|
+
let malformed = false;
|
|
351
|
+
for (const [name, scopes] of Object.entries(entry)) {
|
|
352
|
+
if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) {
|
|
353
|
+
malformed = true;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
req[name] = scopes;
|
|
357
|
+
}
|
|
358
|
+
out.push(malformed ? malformedSecurityRequirement() : req);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The outcome of enforcing an operation's security: `ok` when authorized, otherwise the HTTP
|
|
364
|
+
* status to return — `401` (the request presented no/invalid credential) or `500` (the app is
|
|
365
|
+
* misconfigured: an operation references an unknown scheme, an unsupported scheme type, or a
|
|
366
|
+
* scheme whose secret env var is unset). */
|
|
367
|
+
export interface SecurityDecision {
|
|
368
|
+
ok: boolean;
|
|
369
|
+
status?: 401 | 500;
|
|
370
|
+
error?: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const SECURITY_OK: SecurityDecision = { ok: true };
|
|
374
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
375
|
+
|
|
376
|
+
/** Constant-time string comparison — avoids leaking how many leading characters of a secret matched
|
|
377
|
+
* via response timing. Unequal lengths short-circuit (an acceptable length oracle for API keys). */
|
|
378
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
379
|
+
const ab = TEXT_ENCODER.encode(a);
|
|
380
|
+
const bb = TEXT_ENCODER.encode(b);
|
|
381
|
+
if (ab.length !== bb.length) return false;
|
|
382
|
+
let diff = 0;
|
|
383
|
+
for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
|
|
384
|
+
return diff === 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Enforce an operation's effective security requirements (ADR 0059). Pure: the caller supplies the
|
|
389
|
+
* request's header/query readers and an env reader, so all policy lives here (and is unit-tested)
|
|
390
|
+
* while the impure lookups stay in the runtime.
|
|
391
|
+
*
|
|
392
|
+
* Semantics follow OpenAPI: authorized when ANY one requirement object is satisfied (OR), and a
|
|
393
|
+
* requirement object is satisfied only when ALL its schemes are (AND). An empty requirement list
|
|
394
|
+
* means the operation is open. Only `apiKey` schemes (in `header` or `query`) are enforced; the
|
|
395
|
+
* expected value comes from the scheme's `x-nano-secret-env` env var.
|
|
396
|
+
*
|
|
397
|
+
* A referenced scheme that is unknown, not `apiKey`, or missing its secret env var is a server
|
|
398
|
+
* misconfiguration → `500` (fail closed), reported in preference to a `401` so operators see the
|
|
399
|
+
* real cause. A well-formed scheme with a wrong/absent presented credential → `401`.
|
|
400
|
+
*/
|
|
401
|
+
export function evaluateSecurity(
|
|
402
|
+
doc: OpenApiDoc,
|
|
403
|
+
op: OperationInfo,
|
|
404
|
+
getHeader: (name: string) => string | undefined,
|
|
405
|
+
getQuery: (name: string) => string | undefined,
|
|
406
|
+
getSecret: (envVar: string) => string | undefined,
|
|
407
|
+
): SecurityDecision {
|
|
408
|
+
if (op.security.length === 0) return SECURITY_OK;
|
|
409
|
+
const schemes = doc.components?.securitySchemes ?? {};
|
|
410
|
+
let misconfig: string | undefined;
|
|
411
|
+
|
|
412
|
+
for (const requirement of op.security) {
|
|
413
|
+
const names = Object.keys(requirement);
|
|
414
|
+
if (names.length === 0) return SECURITY_OK; // an empty object is a "no auth" alternative
|
|
415
|
+
let satisfied = true;
|
|
416
|
+
for (const name of names) {
|
|
417
|
+
if (name === MALFORMED_SECURITY_REQUIREMENT) {
|
|
418
|
+
misconfig ??= "malformed security requirement";
|
|
419
|
+
satisfied = false;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
const scheme = schemes[name];
|
|
423
|
+
if (!scheme) {
|
|
424
|
+
misconfig ??= `security scheme "${name}" is not declared in components.securitySchemes`;
|
|
425
|
+
satisfied = false;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (scheme.type !== "apiKey") {
|
|
429
|
+
misconfig ??= `security scheme "${name}" has unsupported type "${scheme.type ?? "(none)"}" (only apiKey is enforced)`;
|
|
430
|
+
satisfied = false;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
const where = scheme.in ?? "header";
|
|
434
|
+
if (where !== "header" && where !== "query") {
|
|
435
|
+
misconfig ??= `security scheme "${name}" apiKey location "${where}" is unsupported (use header or query)`;
|
|
436
|
+
satisfied = false;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
if (!scheme.name) {
|
|
440
|
+
misconfig ??= `security scheme "${name}" is missing its apiKey \`name\``;
|
|
441
|
+
satisfied = false;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
const envVar = scheme["x-nano-secret-env"];
|
|
445
|
+
if (!envVar) {
|
|
446
|
+
misconfig ??= `security scheme "${name}" is missing \`x-nano-secret-env\` (the env var holding the expected key)`;
|
|
447
|
+
satisfied = false;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
const expected = getSecret(envVar);
|
|
451
|
+
if (expected === undefined || expected === "") {
|
|
452
|
+
misconfig ??= `security scheme "${name}" secret env ${envVar} is not set`;
|
|
453
|
+
satisfied = false;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
const presented = where === "header" ? getHeader(scheme.name) : getQuery(scheme.name);
|
|
457
|
+
if (presented === undefined || !timingSafeEqual(presented, expected)) {
|
|
458
|
+
satisfied = false; // a wrong/absent credential → 401 (not a misconfig)
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (satisfied) return SECURITY_OK;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return misconfig
|
|
466
|
+
? { ok: false, status: 500, error: `security misconfigured: ${misconfig}` }
|
|
467
|
+
: { ok: false, status: 401, error: "unauthorized" };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** "scheme (op)" pointers for every operation security requirement that names a scheme absent from
|
|
471
|
+
* components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
|
|
472
|
+
* `--check` gate can surface ahead of time. */
|
|
473
|
+
export function undeclaredSecuritySchemes(doc: OpenApiDoc): string[] {
|
|
474
|
+
const declared = new Set(Object.keys(doc.components?.securitySchemes ?? {}));
|
|
475
|
+
const out: string[] = [];
|
|
476
|
+
for (const op of collectOperations(doc)) {
|
|
477
|
+
for (const requirement of op.security) {
|
|
478
|
+
for (const name of Object.keys(requirement)) {
|
|
479
|
+
if (name !== MALFORMED_SECURITY_REQUIREMENT && !declared.has(name)) {
|
|
480
|
+
out.push(`${name} (${op.method.toUpperCase()} ${op.path})`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
282
488
|
function normalizeParameters(raw: unknown[]): OpenApiParameter[] {
|
|
283
489
|
// OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
|
|
284
490
|
// overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
|
|
@@ -7,12 +7,16 @@ import { makeRouter } from "../router.ts";
|
|
|
7
7
|
import { DataLayer } from "./datasource.ts";
|
|
8
8
|
import { mountApi, resolveOperationHandler, apiDocsPath, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
9
9
|
|
|
10
|
-
function req(
|
|
10
|
+
function req(
|
|
11
|
+
method: string,
|
|
12
|
+
path: string,
|
|
13
|
+
opts: { query?: string; body?: string; headers?: Record<string, string> } = {},
|
|
14
|
+
): HttpRequest {
|
|
11
15
|
return {
|
|
12
16
|
method,
|
|
13
17
|
path,
|
|
14
18
|
query: new URLSearchParams(opts.query ?? ""),
|
|
15
|
-
headers: new Headers(),
|
|
19
|
+
headers: new Headers(opts.headers ?? {}),
|
|
16
20
|
text: () => Promise.resolve(opts.body ?? ""),
|
|
17
21
|
};
|
|
18
22
|
}
|
|
@@ -82,12 +86,13 @@ function build(
|
|
|
82
86
|
api: Record<string, unknown> | undefined,
|
|
83
87
|
modules: Record<string, Record<string, unknown>>,
|
|
84
88
|
specText = spec,
|
|
89
|
+
env: (v: string) => string | undefined = () => undefined,
|
|
85
90
|
): Built {
|
|
86
91
|
const imported: string[] = [];
|
|
87
92
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
88
93
|
const host: HostContext = {
|
|
89
94
|
runtime: "node",
|
|
90
|
-
env
|
|
95
|
+
env,
|
|
91
96
|
readTextFile: async () => specText,
|
|
92
97
|
listDir: async () => [],
|
|
93
98
|
exists: async () => false,
|
|
@@ -521,3 +526,74 @@ test("apiDocsPath resolves the UI route (and honours disable / override)", () =>
|
|
|
521
526
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
|
|
522
527
|
assert.equal(apiDocsPath({}), undefined);
|
|
523
528
|
});
|
|
529
|
+
|
|
530
|
+
// ── Security enforcement (ADR 0059: apiKey-guarded webhook operations) ────────────────────────
|
|
531
|
+
|
|
532
|
+
const securedSpec = JSON.stringify({
|
|
533
|
+
openapi: "3.0.0",
|
|
534
|
+
components: {
|
|
535
|
+
securitySchemes: {
|
|
536
|
+
webhookKey: { type: "apiKey", in: "header", name: "X-Webhook-Key", "x-nano-secret-env": "NANO_WEBHOOK_KEY" },
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
paths: {
|
|
540
|
+
"/hooks/submit": {
|
|
541
|
+
post: {
|
|
542
|
+
operationId: "submitHook",
|
|
543
|
+
security: [{ webhookKey: [] }],
|
|
544
|
+
responses: { "204": {} },
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("secured operation: correct apiKey header reaches the delegate (200)", async () => {
|
|
551
|
+
const seen: unknown[] = [];
|
|
552
|
+
const handler: OperationHandler = (input) => {
|
|
553
|
+
seen.push(input);
|
|
554
|
+
return { status: 200, body: { ok: true } };
|
|
555
|
+
};
|
|
556
|
+
const { router, imported } = build(
|
|
557
|
+
{ spec: "openapi.json", dir: "operations" },
|
|
558
|
+
{ "/app/operations/submitHook": { default: handler } },
|
|
559
|
+
securedSpec,
|
|
560
|
+
(v) => (v === "NANO_WEBHOOK_KEY" ? "s3cret" : undefined),
|
|
561
|
+
);
|
|
562
|
+
const res = await router(
|
|
563
|
+
req("POST", "/app/api/hooks/submit", { body: "{}", headers: { "X-Webhook-Key": "s3cret" } }),
|
|
564
|
+
);
|
|
565
|
+
assert.equal(res.status, 200);
|
|
566
|
+
assert.equal(seen.length, 1);
|
|
567
|
+
assert.deepEqual(imported, ["/app/operations/submitHook"]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("secured operation: missing/wrong apiKey is rejected 401 without loading the delegate", async () => {
|
|
571
|
+
const { router, imported } = build(
|
|
572
|
+
{ spec: "openapi.json", dir: "operations" },
|
|
573
|
+
{ "/app/operations/submitHook": { default: () => ({ status: 200 }) } },
|
|
574
|
+
securedSpec,
|
|
575
|
+
(v) => (v === "NANO_WEBHOOK_KEY" ? "s3cret" : undefined),
|
|
576
|
+
);
|
|
577
|
+
const missing = await router(req("POST", "/app/api/hooks/submit", { body: "{}" }));
|
|
578
|
+
assert.equal(missing.status, 401);
|
|
579
|
+
const wrong = await router(
|
|
580
|
+
req("POST", "/app/api/hooks/submit", { body: "{}", headers: { "X-Webhook-Key": "nope" } }),
|
|
581
|
+
);
|
|
582
|
+
assert.equal(wrong.status, 401);
|
|
583
|
+
// The delegate must never run for an unauthorized request.
|
|
584
|
+
assert.deepEqual(imported, []);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("secured operation: an unset secret env fails closed with 500 (misconfiguration)", async () => {
|
|
588
|
+
const { router, logs } = build(
|
|
589
|
+
{ spec: "openapi.json", dir: "operations" },
|
|
590
|
+
{ "/app/operations/submitHook": { default: () => ({ status: 200 }) } },
|
|
591
|
+
securedSpec,
|
|
592
|
+
() => undefined, // NANO_WEBHOOK_KEY not set
|
|
593
|
+
);
|
|
594
|
+
const res = await router(
|
|
595
|
+
req("POST", "/app/api/hooks/submit", { body: "{}", headers: { "X-Webhook-Key": "anything" } }),
|
|
596
|
+
);
|
|
597
|
+
assert.equal(res.status, 500);
|
|
598
|
+
assert.ok(logs.some((l) => l.level === "error" && /security is misconfigured/.test(l.msg)));
|
|
599
|
+
});
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
resolveSchema,
|
|
30
30
|
toRouteMatcher,
|
|
31
31
|
undeclaredPathParams,
|
|
32
|
+
undeclaredSecuritySchemes,
|
|
33
|
+
evaluateSecurity,
|
|
32
34
|
validateValue,
|
|
33
35
|
type ValidationIssue,
|
|
34
36
|
} from "../../../openapi/spec.ts";
|
|
@@ -336,6 +338,14 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
336
338
|
{ unsafe },
|
|
337
339
|
);
|
|
338
340
|
}
|
|
341
|
+
const undeclaredSchemes = undeclaredSecuritySchemes(d);
|
|
342
|
+
if (undeclaredSchemes.length > 0) {
|
|
343
|
+
ctx.host.log(
|
|
344
|
+
"warn",
|
|
345
|
+
"OpenAPI operations require a security scheme not declared in components.securitySchemes — those requests are rejected with 500 (ADR 0059)",
|
|
346
|
+
{ undeclared: undeclaredSchemes },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
339
349
|
const mounted = collectOperations(d).map((op) => {
|
|
340
350
|
const { pattern, paramNames } = toRouteMatcher(base, op.path);
|
|
341
351
|
return { op, pattern, paramNames };
|
|
@@ -384,6 +394,26 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
384
394
|
const { op } = match;
|
|
385
395
|
const ejected = surfaceEject || op.eject;
|
|
386
396
|
|
|
397
|
+
// Enforce the operation's security requirements (ADR 0059) before touching the body or the
|
|
398
|
+
// delegate: an unauthorized request should do no work and reveal nothing. Enforced even for
|
|
399
|
+
// ejected ops (eject changes body handling, not who may call the operation).
|
|
400
|
+
const authz = evaluateSecurity(
|
|
401
|
+
d,
|
|
402
|
+
op,
|
|
403
|
+
(name) => req.headers.get(name) ?? undefined,
|
|
404
|
+
(name) => req.query.get(name) ?? undefined,
|
|
405
|
+
(envVar) => ctx.host.env(envVar),
|
|
406
|
+
);
|
|
407
|
+
if (!authz.ok) {
|
|
408
|
+
if (authz.status === 500) {
|
|
409
|
+
ctx.host.log("error", "OpenAPI operation security is misconfigured (ADR 0059)", {
|
|
410
|
+
operationId: op.operationId,
|
|
411
|
+
reason: authz.error,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return json({ error: authz.error ?? "unauthorized" }, authz.status ?? 401);
|
|
415
|
+
}
|
|
416
|
+
|
|
387
417
|
// Path params from the capture groups; query as a single-or-array map.
|
|
388
418
|
const captures = match.pattern.exec(req.path) ?? [];
|
|
389
419
|
const params: Record<string, string> = {};
|