@nanobpm/urban 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli.d.ts +0 -1
  2. package/dist/cli.js +32 -47
  3. package/dist/openapi/spec.d.ts +63 -0
  4. package/dist/openapi/spec.js +168 -0
  5. package/dist/runtime/core/modules/api.d.ts +19 -4
  6. package/dist/runtime/core/modules/api.js +99 -4
  7. package/dist/runtime/core/runtime.d.ts +10 -0
  8. package/dist/runtime/core/runtime.js +2 -2
  9. package/dist/runtime/index.d.ts +3 -1
  10. package/dist/runtime/index.js +2 -1
  11. package/dist/toolkit/api-dir.d.ts +11 -0
  12. package/dist/toolkit/api-dir.js +36 -0
  13. package/dist/toolkit/derivers/api.d.ts +25 -2
  14. package/dist/toolkit/derivers/api.js +60 -5
  15. package/dist/toolkit/gen.js +4 -1
  16. package/dist/toolkit/generate.d.ts +27 -0
  17. package/dist/toolkit/generate.js +47 -0
  18. package/dist/toolkit/index.d.ts +8 -1
  19. package/dist/toolkit/index.js +5 -1
  20. package/dist/toolkit/scaffold/operations.d.ts +21 -0
  21. package/dist/toolkit/scaffold/operations.js +55 -0
  22. package/dist/toolkit/scaffold/workers.js +1 -1
  23. package/dist/toolkit/scaffold.d.ts +16 -0
  24. package/dist/toolkit/scaffold.js +40 -0
  25. package/package.json +74 -74
  26. package/src/cli.test.ts +0 -1
  27. package/src/cli.ts +30 -45
  28. package/src/openapi/spec.test.ts +171 -0
  29. package/src/openapi/spec.ts +219 -1
  30. package/src/runtime/core/modules/api.test.ts +183 -5
  31. package/src/runtime/core/modules/api.ts +125 -8
  32. package/src/runtime/core/runtime.test.ts +53 -1
  33. package/src/runtime/core/runtime.ts +12 -2
  34. package/src/runtime/index.ts +3 -1
  35. package/src/toolkit/api-dir.test.ts +32 -0
  36. package/src/toolkit/api-dir.ts +41 -0
  37. package/src/toolkit/derivers/api.test.ts +41 -3
  38. package/src/toolkit/derivers/api.ts +75 -5
  39. package/src/toolkit/gen.ts +5 -2
  40. package/src/toolkit/generate.test.ts +135 -0
  41. package/src/toolkit/generate.ts +71 -0
  42. package/src/toolkit/index.ts +10 -0
  43. package/src/toolkit/scaffold/operations.test.ts +136 -0
  44. package/src/toolkit/scaffold/operations.ts +75 -0
  45. package/src/toolkit/scaffold/workers.ts +1 -1
  46. package/src/toolkit/scaffold.ts +56 -0
@@ -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
  });
@@ -228,6 +265,30 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
228
265
  return out;
229
266
  }
230
267
 
268
+ /**
269
+ * Throw if the document declares the same `operationId` on more than one operation. An operationId
270
+ * must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
271
+ * AND the generated controller registry key), so a duplicate would silently overwrite an earlier
272
+ * entry in the emitted object literal — dispatching the wrong delegate while still type-checking — and
273
+ * make the scaffolder plan the same stub twice. Called by the gen/scaffold path so `urban gen`
274
+ * fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
275
+ */
276
+ export function assertUniqueOperationIds(doc: OpenApiDoc): void {
277
+ const seen = new Set<string>();
278
+ const duplicates = new Set<string>();
279
+ for (const op of collectOperations(doc)) {
280
+ if (seen.has(op.operationId)) duplicates.add(op.operationId);
281
+ seen.add(op.operationId);
282
+ }
283
+ if (duplicates.size > 0) {
284
+ const list = [...duplicates].sort().join(", ");
285
+ throw new Error(
286
+ `OpenAPI spec declares duplicate operationId(s): ${list}. Each operationId must be unique — ` +
287
+ "it names the delegate module and the generated controller registry key.",
288
+ );
289
+ }
290
+ }
291
+
231
292
  /** "method path" pointers for every operation that declares no `operationId`. Such operations are
232
293
  * unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
233
294
  export function operationsWithoutId(doc: OpenApiDoc): string[] {
@@ -291,6 +352,163 @@ export function operationsWithUnsafeId(doc: OpenApiDoc): string[] {
291
352
  return out;
292
353
  }
293
354
 
355
+ const MALFORMED_SECURITY_REQUIREMENT = "__nano_malformed_security_requirement__";
356
+
357
+ function malformedSecurityRequirement(): OpenApiSecurityRequirement {
358
+ return { [MALFORMED_SECURITY_REQUIREMENT]: [] };
359
+ }
360
+
361
+ /** Normalize a raw `security` value into a clean requirement list. Missing → `[]` (open).
362
+ * Present-but-malformed values become an impossible marker requirement so enforcement fails closed
363
+ * as a 500 misconfiguration instead of silently opening a guarded operation. */
364
+ function normalizeSecurity(raw: unknown): OpenApiSecurityRequirement[] {
365
+ if (raw === undefined) return [];
366
+ if (!Array.isArray(raw)) return [malformedSecurityRequirement()];
367
+ const out: OpenApiSecurityRequirement[] = [];
368
+ for (const entry of raw) {
369
+ if (!isRecord(entry)) {
370
+ out.push(malformedSecurityRequirement());
371
+ continue;
372
+ }
373
+ const req: OpenApiSecurityRequirement = {};
374
+ let malformed = false;
375
+ for (const [name, scopes] of Object.entries(entry)) {
376
+ if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) {
377
+ malformed = true;
378
+ break;
379
+ }
380
+ req[name] = scopes;
381
+ }
382
+ out.push(malformed ? malformedSecurityRequirement() : req);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ /** The outcome of enforcing an operation's security: `ok` when authorized, otherwise the HTTP
388
+ * status to return — `401` (the request presented no/invalid credential) or `500` (the app is
389
+ * misconfigured: an operation references an unknown scheme, an unsupported scheme type, or a
390
+ * scheme whose secret env var is unset). */
391
+ export interface SecurityDecision {
392
+ ok: boolean;
393
+ status?: 401 | 500;
394
+ error?: string;
395
+ }
396
+
397
+ const SECURITY_OK: SecurityDecision = { ok: true };
398
+ const TEXT_ENCODER = new TextEncoder();
399
+
400
+ /** Constant-time string comparison — avoids leaking how many leading characters of a secret matched
401
+ * via response timing. Unequal lengths short-circuit (an acceptable length oracle for API keys). */
402
+ function timingSafeEqual(a: string, b: string): boolean {
403
+ const ab = TEXT_ENCODER.encode(a);
404
+ const bb = TEXT_ENCODER.encode(b);
405
+ if (ab.length !== bb.length) return false;
406
+ let diff = 0;
407
+ for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
408
+ return diff === 0;
409
+ }
410
+
411
+ /**
412
+ * Enforce an operation's effective security requirements (ADR 0059). Pure: the caller supplies the
413
+ * request's header/query readers and an env reader, so all policy lives here (and is unit-tested)
414
+ * while the impure lookups stay in the runtime.
415
+ *
416
+ * Semantics follow OpenAPI: authorized when ANY one requirement object is satisfied (OR), and a
417
+ * requirement object is satisfied only when ALL its schemes are (AND). An empty requirement list
418
+ * means the operation is open. Only `apiKey` schemes (in `header` or `query`) are enforced; the
419
+ * expected value comes from the scheme's `x-nano-secret-env` env var.
420
+ *
421
+ * A referenced scheme that is unknown, not `apiKey`, or missing its secret env var is a server
422
+ * misconfiguration → `500` (fail closed), reported in preference to a `401` so operators see the
423
+ * real cause. A well-formed scheme with a wrong/absent presented credential → `401`.
424
+ */
425
+ export function evaluateSecurity(
426
+ doc: OpenApiDoc,
427
+ op: OperationInfo,
428
+ getHeader: (name: string) => string | undefined,
429
+ getQuery: (name: string) => string | undefined,
430
+ getSecret: (envVar: string) => string | undefined,
431
+ ): SecurityDecision {
432
+ if (op.security.length === 0) return SECURITY_OK;
433
+ const schemes = doc.components?.securitySchemes ?? {};
434
+ let misconfig: string | undefined;
435
+
436
+ for (const requirement of op.security) {
437
+ const names = Object.keys(requirement);
438
+ if (names.length === 0) return SECURITY_OK; // an empty object is a "no auth" alternative
439
+ let satisfied = true;
440
+ for (const name of names) {
441
+ if (name === MALFORMED_SECURITY_REQUIREMENT) {
442
+ misconfig ??= "malformed security requirement";
443
+ satisfied = false;
444
+ break;
445
+ }
446
+ const scheme = schemes[name];
447
+ if (!scheme) {
448
+ misconfig ??= `security scheme "${name}" is not declared in components.securitySchemes`;
449
+ satisfied = false;
450
+ break;
451
+ }
452
+ if (scheme.type !== "apiKey") {
453
+ misconfig ??= `security scheme "${name}" has unsupported type "${scheme.type ?? "(none)"}" (only apiKey is enforced)`;
454
+ satisfied = false;
455
+ break;
456
+ }
457
+ const where = scheme.in ?? "header";
458
+ if (where !== "header" && where !== "query") {
459
+ misconfig ??= `security scheme "${name}" apiKey location "${where}" is unsupported (use header or query)`;
460
+ satisfied = false;
461
+ break;
462
+ }
463
+ if (!scheme.name) {
464
+ misconfig ??= `security scheme "${name}" is missing its apiKey \`name\``;
465
+ satisfied = false;
466
+ break;
467
+ }
468
+ const envVar = scheme["x-nano-secret-env"];
469
+ if (!envVar) {
470
+ misconfig ??= `security scheme "${name}" is missing \`x-nano-secret-env\` (the env var holding the expected key)`;
471
+ satisfied = false;
472
+ break;
473
+ }
474
+ const expected = getSecret(envVar);
475
+ if (expected === undefined || expected === "") {
476
+ misconfig ??= `security scheme "${name}" secret env ${envVar} is not set`;
477
+ satisfied = false;
478
+ break;
479
+ }
480
+ const presented = where === "header" ? getHeader(scheme.name) : getQuery(scheme.name);
481
+ if (presented === undefined || !timingSafeEqual(presented, expected)) {
482
+ satisfied = false; // a wrong/absent credential → 401 (not a misconfig)
483
+ break;
484
+ }
485
+ }
486
+ if (satisfied) return SECURITY_OK;
487
+ }
488
+
489
+ return misconfig
490
+ ? { ok: false, status: 500, error: `security misconfigured: ${misconfig}` }
491
+ : { ok: false, status: 401, error: "unauthorized" };
492
+ }
493
+
494
+ /** "scheme (op)" pointers for every operation security requirement that names a scheme absent from
495
+ * components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
496
+ * `--check` gate can surface ahead of time. */
497
+ export function undeclaredSecuritySchemes(doc: OpenApiDoc): string[] {
498
+ const declared = new Set(Object.keys(doc.components?.securitySchemes ?? {}));
499
+ const out: string[] = [];
500
+ for (const op of collectOperations(doc)) {
501
+ for (const requirement of op.security) {
502
+ for (const name of Object.keys(requirement)) {
503
+ if (name !== MALFORMED_SECURITY_REQUIREMENT && !declared.has(name)) {
504
+ out.push(`${name} (${op.method.toUpperCase()} ${op.path})`);
505
+ }
506
+ }
507
+ }
508
+ }
509
+ return out;
510
+ }
511
+
294
512
  function normalizeParameters(raw: unknown[]): OpenApiParameter[] {
295
513
  // OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
296
514
  // overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
@@ -5,14 +5,18 @@ import type { AppManifest } from "../manifest.ts";
5
5
  import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
6
6
  import { makeRouter } from "../router.ts";
7
7
  import { DataLayer } from "./datasource.ts";
8
- import { mountApi, resolveOperationHandler, apiDocsPath, type ApiHandle, type OperationHandler } from "./api.ts";
8
+ import { mountApi, resolveOperationHandler, apiDocsPath, NotImplemented, 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,15 +86,18 @@ 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,
90
+ existsPaths: string[] = [],
85
91
  ): Built {
86
92
  const imported: string[] = [];
87
93
  const logs: Array<{ level: string; msg: string }> = [];
94
+ const existsSet = new Set(existsPaths);
88
95
  const host: HostContext = {
89
96
  runtime: "node",
90
- env: () => undefined,
97
+ env,
91
98
  readTextFile: async () => specText,
92
99
  listDir: async () => [],
93
- exists: async () => false,
100
+ exists: async (p: string) => existsSet.has(p),
94
101
  openSqlite: () => {
95
102
  throw new Error("sqlite not used in this test");
96
103
  },
@@ -521,3 +528,174 @@ test("apiDocsPath resolves the UI route (and honours disable / override)", () =>
521
528
  assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
522
529
  assert.equal(apiDocsPath({}), undefined);
523
530
  });
531
+
532
+ // ── Security enforcement (ADR 0059: apiKey-guarded webhook operations) ────────────────────────
533
+
534
+ const securedSpec = JSON.stringify({
535
+ openapi: "3.0.0",
536
+ components: {
537
+ securitySchemes: {
538
+ webhookKey: { type: "apiKey", in: "header", name: "X-Webhook-Key", "x-nano-secret-env": "NANO_WEBHOOK_KEY" },
539
+ },
540
+ },
541
+ paths: {
542
+ "/hooks/submit": {
543
+ post: {
544
+ operationId: "submitHook",
545
+ security: [{ webhookKey: [] }],
546
+ responses: { "204": {} },
547
+ },
548
+ },
549
+ },
550
+ });
551
+
552
+ test("secured operation: correct apiKey header reaches the delegate (200)", async () => {
553
+ const seen: unknown[] = [];
554
+ const handler: OperationHandler = (input) => {
555
+ seen.push(input);
556
+ return { status: 200, body: { ok: true } };
557
+ };
558
+ const { router, imported } = build(
559
+ { spec: "openapi.json", dir: "operations" },
560
+ { "/app/operations/submitHook": { default: handler } },
561
+ securedSpec,
562
+ (v) => (v === "NANO_WEBHOOK_KEY" ? "s3cret" : undefined),
563
+ );
564
+ const res = await router(
565
+ req("POST", "/app/api/hooks/submit", { body: "{}", headers: { "X-Webhook-Key": "s3cret" } }),
566
+ );
567
+ assert.equal(res.status, 200);
568
+ assert.equal(seen.length, 1);
569
+ assert.deepEqual(imported, ["/app/operations/submitHook"]);
570
+ });
571
+
572
+ test("secured operation: missing/wrong apiKey is rejected 401 without loading the delegate", async () => {
573
+ const { router, imported } = build(
574
+ { spec: "openapi.json", dir: "operations" },
575
+ { "/app/operations/submitHook": { default: () => ({ status: 200 }) } },
576
+ securedSpec,
577
+ (v) => (v === "NANO_WEBHOOK_KEY" ? "s3cret" : undefined),
578
+ );
579
+ const missing = await router(req("POST", "/app/api/hooks/submit", { body: "{}" }));
580
+ assert.equal(missing.status, 401);
581
+ const wrong = await router(
582
+ req("POST", "/app/api/hooks/submit", { body: "{}", headers: { "X-Webhook-Key": "nope" } }),
583
+ );
584
+ assert.equal(wrong.status, 401);
585
+ // The delegate must never run for an unauthorized request.
586
+ assert.deepEqual(imported, []);
587
+ });
588
+
589
+ test("secured operation: an unset secret env fails closed with 500 (misconfiguration)", async () => {
590
+ const { router, logs } = build(
591
+ { spec: "openapi.json", dir: "operations" },
592
+ { "/app/operations/submitHook": { default: () => ({ status: 200 }) } },
593
+ securedSpec,
594
+ () => undefined, // NANO_WEBHOOK_KEY not set
595
+ );
596
+ const res = await router(
597
+ req("POST", "/app/api/hooks/submit", { body: "{}", headers: { "X-Webhook-Key": "anything" } }),
598
+ );
599
+ assert.equal(res.status, 500);
600
+ assert.ok(logs.some((l) => l.level === "error" && /security is misconfigured/.test(l.msg)));
601
+ });
602
+
603
+ // ── Generated controller registry (ADR 0059) ────────────────────────────────────────────────────
604
+ // When `nano-generated/controller.ts` is present, dispatch resolves delegates from its exported
605
+ // `operations` map (deterministic, drift-checked) instead of per-op path imports.
606
+
607
+ test("controller present: dispatch uses the registry, not a per-op import", async () => {
608
+ const seen: unknown[] = [];
609
+ const handler: OperationHandler = (input) => {
610
+ seen.push(input.params);
611
+ return { status: 200, body: { ok: true } };
612
+ };
613
+ const { router, imported } = build(
614
+ { spec: "openapi.json" },
615
+ {
616
+ "/app/nano-generated/controller": { operations: { getInvoice: handler } },
617
+ },
618
+ spec,
619
+ () => undefined,
620
+ ["/app/nano-generated/controller.ts"],
621
+ );
622
+ const res = await router(req("GET", "/app/api/invoices/7"));
623
+ assert.equal(res.status, 200);
624
+ assert.deepEqual(seen, [{ id: "7" }]);
625
+ // Only the controller barrel is imported — never a per-operation delegate path.
626
+ assert.deepEqual(imported, ["/app/nano-generated/controller"]);
627
+ });
628
+
629
+ test("controller present: request validation still runs before the registry delegate", async () => {
630
+ const handler: OperationHandler = () => ({ status: 201, body: { id: "i", amount: 1 } });
631
+ const { router } = build(
632
+ { spec: "openapi.json" },
633
+ { "/app/nano-generated/controller": { operations: { createInvoice: handler } } },
634
+ spec,
635
+ () => undefined,
636
+ ["/app/nano-generated/controller.ts"],
637
+ );
638
+ // amount:0 violates minimum:1 → a 400 from validation, before the delegate.
639
+ const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ id: "i", amount: 0 }) }));
640
+ assert.equal(res.status, 400);
641
+ assert.match(JSON.parse(res.body!).error, /request validation failed/);
642
+ });
643
+
644
+ test("controller present but missing a delegate key → 500 with a run-`urban gen` hint", async () => {
645
+ const { router, logs } = build(
646
+ { spec: "openapi.json" },
647
+ { "/app/nano-generated/controller": { operations: {} } }, // stale: getInvoice not registered
648
+ spec,
649
+ () => undefined,
650
+ ["/app/nano-generated/controller.ts"],
651
+ );
652
+ const res = await router(req("GET", "/app/api/invoices/7"));
653
+ assert.equal(res.status, 500);
654
+ assert.match(JSON.parse(res.body!).error, /is not registered/);
655
+ assert.ok(logs.some((l) => l.level === "error" && /not registered in the generated controller/.test(l.msg)));
656
+ });
657
+
658
+ test("a NotImplemented throw from a delegate maps to HTTP 501", async () => {
659
+ const stub: OperationHandler = () => {
660
+ throw new NotImplemented("getInvoice");
661
+ };
662
+ const { router } = build(
663
+ { spec: "openapi.json" },
664
+ { "/app/nano-generated/controller": { operations: { getInvoice: stub } } },
665
+ spec,
666
+ () => undefined,
667
+ ["/app/nano-generated/controller.ts"],
668
+ );
669
+ const res = await router(req("GET", "/app/api/invoices/7"));
670
+ assert.equal(res.status, 501);
671
+ const bodyJson = JSON.parse(res.body!);
672
+ assert.equal(bodyJson.operationId, "getInvoice");
673
+ assert.match(bodyJson.error, /not implemented/);
674
+ });
675
+
676
+ test("no controller present → back-compat per-op import path still works", async () => {
677
+ const { router, imported } = build(
678
+ { spec: "openapi.json" },
679
+ { "/app/operations/getInvoice": { default: () => ({ body: { ok: true } }) } },
680
+ // no exists paths → controller absent
681
+ );
682
+ const res = await router(req("GET", "/app/api/invoices/9"));
683
+ assert.equal(res.status, 200);
684
+ assert.deepEqual(imported, ["/app/operations/getInvoice"]);
685
+ });
686
+
687
+ test("compiled app: controller.js present (no .ts) → still uses the registry", async () => {
688
+ // A published / `urban run` build ships controller.js with TS sources absent. Probing only .ts
689
+ // would wrongly report the registry missing and fall back to per-op imports; probe all extensions.
690
+ const handler: OperationHandler = () => ({ status: 200, body: { ok: true } });
691
+ const { router, imported } = build(
692
+ { spec: "openapi.json" },
693
+ { "/app/nano-generated/controller": { operations: { getInvoice: handler } } },
694
+ spec,
695
+ () => undefined,
696
+ ["/app/nano-generated/controller.js"], // only the compiled artifact exists on disk
697
+ );
698
+ const res = await router(req("GET", "/app/api/invoices/7"));
699
+ assert.equal(res.status, 200);
700
+ assert.deepEqual(imported, ["/app/nano-generated/controller"]);
701
+ });