@nanobpm/urban 0.34.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.
@@ -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
  }
@@ -109,6 +135,34 @@ export declare function isSafeOperationId(id: string): boolean;
109
135
  * path segment. collectOperations skips these (they never mount or drive an import); the runtime
110
136
  * surfaces them at `warn`. */
111
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[];
112
166
  /** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
113
167
  * a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
114
168
  * runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
@@ -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
  });
@@ -205,6 +212,145 @@ export function operationsWithUnsafeId(doc) {
205
212
  }
206
213
  return out;
207
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
+ }
208
354
  function normalizeParameters(raw) {
209
355
  // OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
210
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
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",
@@ -2,6 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  collectOperations,
5
+ evaluateSecurity,
5
6
  isObjectSchema,
6
7
  isSafeOperationId,
7
8
  type OpenApiDoc,
@@ -12,6 +13,7 @@ import {
12
13
  resolveSchema,
13
14
  toRouteMatcher,
14
15
  undeclaredPathParams,
16
+ undeclaredSecuritySchemes,
15
17
  validateValue,
16
18
  } from "./spec.ts";
17
19
 
@@ -346,3 +348,172 @@ test("validateValue handles null: typeless object rejects it; nullable/type-null
346
348
  // A truly empty schema allows anything, including null.
347
349
  assert.equal(validateValue(doc, {}, null).length, 0);
348
350
  });
351
+
352
+ // ── Security (ADR 0059: one HTTP surface, apiKey-guarded webhook operations) ──────────────────
353
+
354
+ const secured: OpenApiDoc = {
355
+ openapi: "3.0.0",
356
+ components: {
357
+ securitySchemes: {
358
+ webhookKey: {
359
+ type: "apiKey",
360
+ in: "header",
361
+ name: "X-Webhook-Key",
362
+ "x-nano-secret-env": "NANO_WEBHOOK_KEY",
363
+ },
364
+ queryKey: {
365
+ type: "apiKey",
366
+ in: "query",
367
+ name: "key",
368
+ "x-nano-secret-env": "NANO_QUERY_KEY",
369
+ },
370
+ },
371
+ },
372
+ security: [{ webhookKey: [] }], // document-level default
373
+ paths: {
374
+ "/hooks/submit": {
375
+ post: { operationId: "submitHook", responses: { "204": {} } }, // inherits the default
376
+ },
377
+ "/hooks/open": {
378
+ post: { operationId: "openHook", security: [], responses: { "204": {} } }, // opts out
379
+ },
380
+ "/hooks/query": {
381
+ post: {
382
+ operationId: "queryHook",
383
+ security: [{ queryKey: [] }],
384
+ responses: { "204": {} },
385
+ },
386
+ },
387
+ },
388
+ };
389
+
390
+ const noHeader = (): undefined => undefined;
391
+ const noQuery = (): undefined => undefined;
392
+ const secret = (vars: Record<string, string>) => (v: string): string | undefined => vars[v];
393
+
394
+ test("collectOperations resolves effective security (op-level wins, explicit [] opts out, doc default inherited)", () => {
395
+ const ops = collectOperations(secured);
396
+ const byId = Object.fromEntries(ops.map((o) => [o.operationId, o]));
397
+ assert.deepEqual(byId.submitHook.security, [{ webhookKey: [] }]); // inherited default
398
+ assert.deepEqual(byId.openHook.security, []); // explicit opt-out
399
+ assert.deepEqual(byId.queryHook.security, [{ queryKey: [] }]); // own requirement
400
+ });
401
+
402
+ test("evaluateSecurity: an open operation (no requirement) is always authorized", () => {
403
+ const open = collectOperations(secured).find((o) => o.operationId === "openHook")!;
404
+ assert.deepEqual(
405
+ evaluateSecurity(secured, open, noHeader, noQuery, secret({})),
406
+ { ok: true },
407
+ );
408
+ });
409
+
410
+ test("evaluateSecurity: a valid apiKey header authorizes; wrong/absent → 401", () => {
411
+ const op = collectOperations(secured).find((o) => o.operationId === "submitHook")!;
412
+ const env = secret({ NANO_WEBHOOK_KEY: "s3cret" });
413
+ // Correct key.
414
+ assert.equal(
415
+ evaluateSecurity(secured, op, (n) => (n === "X-Webhook-Key" ? "s3cret" : undefined), noQuery, env).ok,
416
+ true,
417
+ );
418
+ // Wrong key → 401.
419
+ assert.deepEqual(
420
+ evaluateSecurity(secured, op, () => "nope", noQuery, env),
421
+ { ok: false, status: 401, error: "unauthorized" },
422
+ );
423
+ // Missing key → 401.
424
+ assert.deepEqual(evaluateSecurity(secured, op, noHeader, noQuery, env), {
425
+ ok: false,
426
+ status: 401,
427
+ error: "unauthorized",
428
+ });
429
+ });
430
+
431
+ test("evaluateSecurity: an apiKey in query is read from the query, not the header", () => {
432
+ const op = collectOperations(secured).find((o) => o.operationId === "queryHook")!;
433
+ const env = secret({ NANO_QUERY_KEY: "qk" });
434
+ assert.equal(
435
+ evaluateSecurity(secured, op, noHeader, (n) => (n === "key" ? "qk" : undefined), env).ok,
436
+ true,
437
+ );
438
+ assert.equal(evaluateSecurity(secured, op, noHeader, () => "bad", env).status, 401);
439
+ });
440
+
441
+ test("evaluateSecurity: an unset secret env is a 500 misconfiguration (fail closed, not 401)", () => {
442
+ const op = collectOperations(secured).find((o) => o.operationId === "submitHook")!;
443
+ const decision = evaluateSecurity(secured, op, () => "anything", noQuery, secret({}));
444
+ assert.equal(decision.ok, false);
445
+ assert.equal(decision.status, 500);
446
+ assert.match(decision.error ?? "", /NANO_WEBHOOK_KEY is not set/);
447
+ });
448
+
449
+ test("evaluateSecurity: a requirement naming an undeclared scheme is a 500 misconfiguration", () => {
450
+ const doc: OpenApiDoc = {
451
+ openapi: "3.0.0",
452
+ components: { securitySchemes: {} },
453
+ paths: {
454
+ "/x": { post: { operationId: "x", security: [{ ghost: [] }], responses: { "204": {} } } },
455
+ },
456
+ };
457
+ const op = collectOperations(doc)[0];
458
+ const decision = evaluateSecurity(doc, op, () => "k", noQuery, secret({}));
459
+ assert.equal(decision.status, 500);
460
+ assert.match(decision.error ?? "", /not declared/);
461
+ assert.deepEqual(undeclaredSecuritySchemes(doc), ["ghost (POST /x)"]);
462
+ });
463
+
464
+
465
+ test("evaluateSecurity: malformed security entries fail closed as a 500 misconfiguration", () => {
466
+ const doc: OpenApiDoc = {
467
+ openapi: "3.0.0",
468
+ components: {
469
+ securitySchemes: { webhookKey: { type: "apiKey", in: "header", name: "X-Key", "x-nano-secret-env": "KEY" } },
470
+ },
471
+ paths: {
472
+ "/x": { post: { operationId: "x", security: ["webhookKey"], responses: { "204": {} } } },
473
+ },
474
+ };
475
+ const op = collectOperations(doc)[0];
476
+ const decision = evaluateSecurity(doc, op, () => "secret", noQuery, secret({ KEY: "secret" }));
477
+ assert.equal(decision.status, 500);
478
+ assert.match(decision.error ?? "", /malformed security requirement/);
479
+ });
480
+
481
+ test("evaluateSecurity: an unsupported scheme type is a 500 misconfiguration", () => {
482
+ const doc: OpenApiDoc = {
483
+ openapi: "3.0.0",
484
+ components: { securitySchemes: { oauth: { type: "oauth2" } } },
485
+ paths: {
486
+ "/x": { post: { operationId: "x", security: [{ oauth: [] }], responses: { "204": {} } } },
487
+ },
488
+ };
489
+ const op = collectOperations(doc)[0];
490
+ const decision = evaluateSecurity(doc, op, () => "k", noQuery, secret({}));
491
+ assert.equal(decision.status, 500);
492
+ assert.match(decision.error ?? "", /unsupported type "oauth2"/);
493
+ });
494
+
495
+ test("evaluateSecurity: alternative requirements are OR (any one satisfied authorizes)", () => {
496
+ const doc: OpenApiDoc = {
497
+ openapi: "3.0.0",
498
+ components: {
499
+ securitySchemes: {
500
+ a: { type: "apiKey", in: "header", name: "A", "x-nano-secret-env": "A_KEY" },
501
+ b: { type: "apiKey", in: "header", name: "B", "x-nano-secret-env": "B_KEY" },
502
+ },
503
+ },
504
+ paths: {
505
+ "/x": {
506
+ post: { operationId: "x", security: [{ a: [] }, { b: [] }], responses: { "204": {} } },
507
+ },
508
+ },
509
+ };
510
+ const op = collectOperations(doc)[0];
511
+ const env = secret({ A_KEY: "aa", B_KEY: "bb" });
512
+ // Only B presented → still authorized (OR).
513
+ assert.equal(
514
+ evaluateSecurity(doc, op, (n) => (n === "B" ? "bb" : undefined), noQuery, env).ok,
515
+ true,
516
+ );
517
+ // Neither presented → 401.
518
+ assert.equal(evaluateSecurity(doc, op, noHeader, noQuery, env).status, 401);
519
+ });
@@ -84,11 +84,38 @@ export interface OpenApiResponse {
84
84
  content?: Record<string, OpenApiMediaType>;
85
85
  }
86
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
+
87
109
  export interface OpenApiDoc {
88
110
  openapi?: string;
89
111
  info?: { title?: string; version?: string };
90
112
  paths?: Record<string, Record<string, unknown>>;
91
- components?: { schemas?: Record<string, OpenApiSchema> };
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[];
92
119
  }
93
120
 
94
121
  /** The HTTP methods we mount, lowercase as they appear as path-item keys. */
@@ -107,6 +134,9 @@ export interface OperationInfo {
107
134
  requestBodyRequired: boolean;
108
135
  /** Schema of the first 2xx (else default) JSON response, when declared. */
109
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[];
110
140
  eject: boolean;
111
141
  summary?: string;
112
142
  }
@@ -212,6 +242,12 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
212
242
  isRecord(requestBody?.content) ? requestBody.content : undefined,
213
243
  );
214
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);
215
251
  out.push({
216
252
  operationId,
217
253
  method,
@@ -220,6 +256,7 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
220
256
  requestBodySchema,
221
257
  requestBodyRequired: requestBody?.required === true,
222
258
  responseSchema: firstSuccessSchema(responses),
259
+ security,
223
260
  eject: opRaw["x-urban-eject"] === true,
224
261
  summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
225
262
  });
@@ -291,6 +328,163 @@ export function operationsWithUnsafeId(doc: OpenApiDoc): string[] {
291
328
  return out;
292
329
  }
293
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
+
294
488
  function normalizeParameters(raw: unknown[]): OpenApiParameter[] {
295
489
  // OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
296
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(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
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: () => undefined,
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> = {};