@nanobpm/urban 0.31.1 → 0.32.1

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.
@@ -9,6 +9,10 @@ export interface ApiBinding {
9
9
  base?: string;
10
10
  validateResponses?: "dev" | "always" | "never";
11
11
  eject?: boolean;
12
+ /** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
13
+ * off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
14
+ * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
15
+ docs?: boolean | string;
12
16
  }
13
17
  /** The `{ params, query, body }` contract a typed operation is generic over. */
14
18
  export interface OperationContract {
@@ -48,6 +52,10 @@ export type OperationHandler<Req extends OperationContract = DefaultContract, Re
48
52
  export declare function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(_id: string, handler: OperationHandler<Req, Res>): OperationHandler<Req, Res>;
49
53
  /** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
50
54
  export declare function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined;
55
+ /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
56
+ * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
57
+ * the docs without re-deriving the path. */
58
+ export declare function apiDocsPath(manifest: unknown): string | undefined;
51
59
  export interface ApiHandle {
52
60
  readonly name: string;
53
61
  routes: Route[];
@@ -12,9 +12,41 @@
12
12
  // `x-urban-eject: true` OpenAPI vendor extension. The delegate is always additively imperative: it
13
13
  // gets the raw `req` and full `AppApi` on top of the validated input.
14
14
  import { errorMessage } from "../guards.js";
15
- import { json, normalizeRoutePath } from "../router.js";
15
+ import { html, json, normalizeRoutePath } from "../router.js";
16
16
  import { resolveAppPath } from "./datasource.js";
17
17
  import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
18
+ /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
19
+ * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
20
+ * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
21
+ const SWAGGER_UI_VERSION = "5.17.14";
22
+ /** Escape HTML-significant characters before embedding a value (the app name) in the docs markup. */
23
+ function escapeHtml(s) {
24
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
25
+ }
26
+ /** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
27
+ * unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
28
+ * which keeps the docs route decoupled from spec-load failures. */
29
+ function swaggerUiPage(title, specUrl) {
30
+ const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
31
+ const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
32
+ return `<!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1">
37
+ <title>${escapeHtml(title)}</title>
38
+ <link rel="stylesheet" href="${css}">
39
+ <style>body{margin:0}</style>
40
+ </head>
41
+ <body>
42
+ <div id="swagger-ui"></div>
43
+ <script src="${bundle}" crossorigin="anonymous"></script>
44
+ <script>
45
+ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
46
+ </script>
47
+ </body>
48
+ </html>`;
49
+ }
18
50
  /**
19
51
  * Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
20
52
  * `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
@@ -47,6 +79,7 @@ function readApiBinding(manifest) {
47
79
  const base = Reflect.get(raw, "base");
48
80
  const validateResponses = Reflect.get(raw, "validateResponses");
49
81
  const eject = Reflect.get(raw, "eject");
82
+ const docs = Reflect.get(raw, "docs");
50
83
  // Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
51
84
  return {
52
85
  spec: spec.trim(),
@@ -56,8 +89,32 @@ function readApiBinding(manifest) {
56
89
  ? validateResponses
57
90
  : undefined,
58
91
  eject: eject === true,
92
+ // Docs default ON (spec-first apps get Swagger for free); `false` disables; a non-empty
93
+ // string overrides the route. Anything else (including whitespace) falls back to the default.
94
+ docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
59
95
  };
60
96
  }
97
+ /** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
98
+ * serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
99
+ function resolveApiRoutes(binding) {
100
+ const base = normalizeRoutePath(binding.base, "/app/api");
101
+ const docsEnabled = binding.docs !== false;
102
+ // Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
103
+ // of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
104
+ // that owns the `${base}/` prefix (a string `docs` overrides the whole route).
105
+ const docsBase = normalizeRoutePath(typeof binding.docs === "string" ? binding.docs : `${base}-docs`, `${base}-docs`);
106
+ return { base, docsEnabled, docsBase };
107
+ }
108
+ /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
109
+ * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
110
+ * the docs without re-deriving the path. */
111
+ export function apiDocsPath(manifest) {
112
+ const binding = readApiBinding(manifest);
113
+ if (!binding)
114
+ return undefined;
115
+ const { docsEnabled, docsBase } = resolveApiRoutes(binding);
116
+ return docsEnabled ? docsBase : undefined;
117
+ }
61
118
  /** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
62
119
  * validate. A value that will not coerce is left as-is and fails the type check with a clear
63
120
  * message. The delegate still receives the original string(s). */
@@ -89,9 +146,16 @@ export function mountApi(ctx, app) {
89
146
  // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
90
147
  // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
91
148
  // like "app/api" would never match request paths, which always start with "/".
92
- const base = normalizeRoutePath(binding.base, "/app/api");
149
+ //
150
+ // The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
151
+ // this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
152
+ // shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
153
+ // copy of that logic here. The spec JSON is served under the docs path so the operations
154
+ // namespace stays purely operations.
155
+ const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
93
156
  const dir = binding.dir ?? "operations";
94
157
  const surfaceEject = binding.eject === true;
158
+ const specRoutePath = `${docsBase}/openapi.json`;
95
159
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
96
160
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
97
161
  // than failing app boot.
@@ -351,9 +415,57 @@ export function mountApi(ctx, app) {
351
415
  source: `api:${binding.spec}`,
352
416
  handler: dispatch,
353
417
  };
418
+ // Docs routes (ADR 0058): the Swagger UI page + the OpenAPI JSON it reads. Both are exact GET
419
+ // routes on the `${base}-docs` sibling namespace, so they are wholly independent of the
420
+ // operation dispatcher above.
421
+ const serveSpec = async () => {
422
+ let d;
423
+ try {
424
+ d = await loadDoc();
425
+ }
426
+ catch (e) {
427
+ return json({ error: errorMessage(e) }, 500);
428
+ }
429
+ // Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
430
+ // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
431
+ // makes the interactive console call the real Urban routes rather than the origin root.
432
+ return json({ ...d, servers: [{ url: base }] });
433
+ };
434
+ const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
435
+ const serveDocs = () => html(swaggerUiPage(docsTitle, specRoutePath));
436
+ // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
437
+ // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
438
+ // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
439
+ // (slash-less) `docsBase` that `apiDocsPath` and every internal link already use.
440
+ const redirectDocsSlash = () => ({ status: 308, headers: { location: docsBase } });
441
+ const docsRoutes = docsEnabled
442
+ ? [
443
+ { method: "GET", path: docsBase, source: `api-docs:${binding.spec}`, handler: serveDocs },
444
+ {
445
+ method: "GET",
446
+ path: `${docsBase}/`,
447
+ source: `api-docs:${binding.spec}`,
448
+ handler: redirectDocsSlash,
449
+ },
450
+ {
451
+ method: "GET",
452
+ path: specRoutePath,
453
+ source: `api-docs:${binding.spec}`,
454
+ handler: serveSpec,
455
+ },
456
+ ]
457
+ : [];
354
458
  return {
355
459
  name: "api",
356
- routes: [rootRoute, route],
357
- describe: () => ({ api: { spec: binding.spec, base, dir, eject: surfaceEject } }),
460
+ routes: [...docsRoutes, rootRoute, route],
461
+ describe: () => ({
462
+ api: {
463
+ spec: binding.spec,
464
+ base,
465
+ dir,
466
+ eject: surfaceEject,
467
+ docs: docsEnabled ? { ui: docsBase, spec: specRoutePath } : false,
468
+ },
469
+ }),
358
470
  };
359
471
  }
@@ -18,6 +18,12 @@ export interface PagesOptions {
18
18
  rowLimit?: number;
19
19
  /** The injected default datasource name (the alias apps bind to). Default `app`. */
20
20
  sourceName?: string;
21
+ /**
22
+ * The app's Swagger UI route (from the `api` binding, resolved by `apiDocsPath`). When set,
23
+ * the shell renders a persistent "API docs" badge linking to it — so a spec-first app surfaces
24
+ * its interactive docs from its own UI for free. Omitted → no badge (app declares no `api`).
25
+ */
26
+ apiDocsPath?: string;
21
27
  }
22
28
  export interface PagesDeps {
23
29
  db: PagesDataSource;
@@ -17,6 +17,7 @@
17
17
  import { errorMessage, isRecord } from "../guards.js";
18
18
  import { html, json } from "../router.js";
19
19
  import { cancelInstanceReconciling } from "./cancel.js";
20
+ import { apiDocsPath } from "./api.js";
20
21
  import { quoteIdent } from "./gateway.js";
21
22
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
22
23
  function javascript(body, status = 200) {
@@ -66,7 +67,7 @@ export function createPagesRoutes(opts, deps) {
66
67
  return p;
67
68
  };
68
69
  const routes = [];
69
- const shell = html(rendererShell(homePage));
70
+ const shell = html(rendererShell(homePage, opts.apiDocsPath));
70
71
  // ── the renderer shell + module ─────────────────────────────────────────
71
72
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
72
73
  routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
@@ -335,6 +336,9 @@ export function mountPages(ctx, app) {
335
336
  homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
336
337
  rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
337
338
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
339
+ // Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
340
+ // binding (resolved by the api module, so the path never drifts from where docs mount).
341
+ apiDocsPath: apiDocsPath(ctx.manifest),
338
342
  };
339
343
  const sourceName = opts.sourceName ?? "app";
340
344
  const bindings = ctx.manifest.instanceTracking ?? [];
@@ -361,7 +365,13 @@ export function mountPages(ctx, app) {
361
365
  function escapeAttr(s) {
362
366
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
363
367
  }
364
- function rendererShell(homePage) {
368
+ function rendererShell(homePage, apiDocsPath) {
369
+ // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
370
+ // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
371
+ // tab a handle back to this window (reverse-tabnabbing).
372
+ const apiDocsBadge = apiDocsPath
373
+ ? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
374
+ : "";
365
375
  return `<!doctype html>
366
376
  <html lang="en">
367
377
  <head>
@@ -370,7 +380,7 @@ function rendererShell(homePage) {
370
380
  <title>Urban App</title>
371
381
  <style>${RENDERER_CSS}</style>
372
382
  </head>
373
- <body>
383
+ <body>${apiDocsBadge}
374
384
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
375
385
  <script type="module" src="./app/runtime.js"></script>
376
386
  </body>
@@ -417,6 +427,8 @@ const RENDERER_CSS = `
417
427
  * { box-sizing: border-box; }
418
428
  body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
419
429
  .pc-empty { color:var(--nano-text-faint); }
430
+ .pc-apidocs { position:fixed; top:.75rem; right:.75rem; z-index:10; font-size:.8rem; text-decoration:none; padding:.3rem .6rem; border:1px solid var(--nano-edge); border-radius:999px; background:var(--nano-panel); color:var(--nano-text-muted); }
431
+ .pc-apidocs:hover { color:var(--nano-text); border-color:var(--nano-accent); }
420
432
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
421
433
  .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
422
434
  .pc-body { margin:.5rem 0; }
@@ -663,10 +675,37 @@ function renderActionForm(node) {
663
675
  }
664
676
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
665
677
  try {
666
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
667
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
668
- msg.className = "pc-msg ok";
669
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
678
+ // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
679
+ // publishMessage -> engine.publishMessage, which correlates a message to a message start
680
+ // (or intermediate catch) event. correlationKeyField names the form field that carries
681
+ // the correlation key; the whole form becomes the message variables.
682
+ if (p.action && p.action.kind === "publishMessage") {
683
+ const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
684
+ if (!correlationKey) {
685
+ msg.className = "pc-msg err";
686
+ msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
687
+ return;
688
+ }
689
+ // Send the trimmed key back as the variable too, so the value a worker
690
+ // reads matches the key the message was correlated on (no leading/trailing
691
+ // whitespace divergence between correlationKey and variables[field]).
692
+ variables[p.action.correlationKeyField] = correlationKey;
693
+ await getJSON("/app/actions/message", { method: "POST",
694
+ headers: { "content-type": "application/json" },
695
+ body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
696
+ msg.className = "pc-msg ok";
697
+ msg.textContent = "Message published";
698
+ } else {
699
+ if (!p.action || !p.action.process) {
700
+ msg.className = "pc-msg err";
701
+ msg.textContent = "This form has no action configured";
702
+ return;
703
+ }
704
+ const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
705
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
706
+ msg.className = "pc-msg ok";
707
+ msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
708
+ }
670
709
  for (const input of inputs.values()) input.value = "";
671
710
  document.dispatchEvent(new CustomEvent("pc:refresh"));
672
711
  } catch (e) {
@@ -12,10 +12,13 @@ export declare function json(body: unknown, status?: number): HttpResponse;
12
12
  export declare function html(body: string, status?: number): HttpResponse;
13
13
  export declare function text(body: string, status?: number): HttpResponse;
14
14
  /**
15
- * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
16
- * slashes. Incoming request paths always start with "/", so a manifest path
17
- * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
18
- * `fallback` when the value is missing or collapses to empty ("/").
15
+ * Normalize a manifest-supplied route path: collapse leading slashes to exactly
16
+ * one, drop trailing slashes. Incoming request paths always start with "/", so a
17
+ * manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
18
+ * multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
19
+ * entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
20
+ * path instead of becoming a protocol-relative URL that navigates off-origin. Falls
21
+ * back to `fallback` when the value is missing or collapses to empty ("/").
19
22
  */
20
23
  export declare function normalizeRoutePath(value: string | undefined, fallback: string): string;
21
24
  /** Build a single HttpHandler that dispatches across the given routes. */
@@ -11,14 +11,17 @@ export function text(body, status = 200) {
11
11
  return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
12
12
  }
13
13
  /**
14
- * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
15
- * slashes. Incoming request paths always start with "/", so a manifest path
16
- * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
17
- * `fallback` when the value is missing or collapses to empty ("/").
14
+ * Normalize a manifest-supplied route path: collapse leading slashes to exactly
15
+ * one, drop trailing slashes. Incoming request paths always start with "/", so a
16
+ * manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
17
+ * multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
18
+ * entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
19
+ * path instead of becoming a protocol-relative URL that navigates off-origin. Falls
20
+ * back to `fallback` when the value is missing or collapses to empty ("/").
18
21
  */
19
22
  export function normalizeRoutePath(value, fallback) {
20
23
  const raw = (value ?? fallback).trim();
21
- const withLead = raw.startsWith("/") ? raw : `/${raw}`;
24
+ const withLead = `/${raw.replace(/^\/+/, "")}`;
22
25
  const noTrail = withLead.replace(/\/+$/, "");
23
26
  return noTrail || fallback;
24
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.31.1",
3
+ "version": "0.32.1",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
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.7.0"
69
+ "create-urban-app": "^0.8.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^22.20.1",
@@ -5,7 +5,7 @@ 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, type OperationHandler } from "./api.ts";
8
+ import { mountApi, resolveOperationHandler, apiDocsPath, type ApiHandle, type OperationHandler } from "./api.ts";
9
9
 
10
10
  function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
11
11
  return {
@@ -72,9 +72,10 @@ const spec = JSON.stringify({
72
72
  });
73
73
 
74
74
  interface Built {
75
- router: (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
75
+ router: (r: HttpRequest) => Promise<{ status?: number; body?: string; headers?: Record<string, string> }>;
76
76
  imported: string[];
77
77
  logs: Array<{ level: string; msg: string }>;
78
+ handle: ApiHandle;
78
79
  }
79
80
 
80
81
  function build(
@@ -108,7 +109,12 @@ function build(
108
109
  const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
109
110
  const handle = mountApi(ctx, appFixture());
110
111
  const router = makeRouter(handle.routes);
111
- return { router: (r) => Promise.resolve(router(r)), imported, logs };
112
+ return { router: (r) => Promise.resolve(router(r)), imported, logs, handle };
113
+ }
114
+
115
+ /** Mount `mountApi` and return the handle directly (for asserting `describe()`). */
116
+ function buildHandle(api: Record<string, unknown>): { handle: ApiHandle } {
117
+ return { handle: build(api, {}).handle };
112
118
  }
113
119
 
114
120
  test("resolveOperationHandler prefers default (function) then handler", () => {
@@ -443,3 +449,71 @@ test("an unsafe operationId never mounts (skipped) — a crafted spec can't impo
443
449
  assert.equal(res.status, 404); // no route mounted for the skipped op
444
450
  assert.equal(imported.some((p) => p.includes("evil")), false);
445
451
  });
452
+
453
+ test("serves the OpenAPI spec at {base}-docs/openapi.json, rebased to the mounted namespace", async () => {
454
+ const { router } = build({ spec: "openapi.json" }, {});
455
+ const res = await router(req("GET", "/app/api-docs/openapi.json"));
456
+ assert.equal(res.status, 200);
457
+ assert.match(res.headers?.["content-type"] ?? "", /application\/json/);
458
+ const doc = JSON.parse(res.body ?? "{}");
459
+ // The doc is served intact...
460
+ assert.ok(doc.paths["/invoices"], "keeps the operation paths");
461
+ // ...but `servers` is rebased to `base` so Swagger UI "Try it out" hits the real routes
462
+ // (operations mount under /app/api, not the spec's bare /invoices).
463
+ assert.deepEqual(doc.servers, [{ url: "/app/api" }]);
464
+ });
465
+
466
+ test("serves the Swagger UI page at {base}-docs by default", async () => {
467
+ const { router } = build({ spec: "openapi.json" }, {});
468
+ const res = await router(req("GET", "/app/api-docs"));
469
+ assert.equal(res.status, 200);
470
+ assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
471
+ const body = res.body ?? "";
472
+ assert.match(body, /swagger-ui/);
473
+ // The UI reads the spec route we serve.
474
+ assert.match(body, /\/app\/api-docs\/openapi\.json/);
475
+ });
476
+
477
+ test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
478
+ const { router } = build({ spec: "openapi.json" }, {});
479
+ // The router exact-matches non-prefix routes, so `${base}-docs/` (common from proxies/browsers)
480
+ // would 404 without an explicit alias. It must permanent-redirect to the slash-less canonical.
481
+ const res = await router(req("GET", "/app/api-docs/"));
482
+ assert.equal(res.status, 308);
483
+ assert.equal(res.headers?.["location"], "/app/api-docs");
484
+ });
485
+
486
+ test("docs default on is reflected in describe()", () => {
487
+ const { handle } = buildHandle({ spec: "openapi.json" });
488
+ const d = handle.describe();
489
+ assert.deepEqual(d.api, {
490
+ spec: "openapi.json",
491
+ base: "/app/api",
492
+ dir: "operations",
493
+ eject: false,
494
+ docs: { ui: "/app/api-docs", spec: "/app/api-docs/openapi.json" },
495
+ });
496
+ });
497
+
498
+ test("docs:false disables both the UI and the spec route (no reserved paths)", async () => {
499
+ const { router } = build({ spec: "openapi.json", docs: false }, {});
500
+ // With docs off, the docs paths fall through to the operation dispatcher → no such operation.
501
+ assert.equal((await router(req("GET", "/app/api-docs"))).status, 404);
502
+ assert.equal((await router(req("GET", "/app/api-docs/openapi.json"))).status, 404);
503
+ });
504
+
505
+ test("a string docs overrides the UI route (and the spec route beneath it)", async () => {
506
+ const { router } = build({ spec: "openapi.json", docs: "/help" }, {});
507
+ assert.equal((await router(req("GET", "/help"))).status, 200);
508
+ assert.equal((await router(req("GET", "/help/openapi.json"))).status, 200);
509
+ // The default docs path is no longer served.
510
+ assert.equal((await router(req("GET", "/app/api-docs"))).status, 404);
511
+ });
512
+
513
+ test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
514
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
515
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/x-docs");
516
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
517
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
518
+ assert.equal(apiDocsPath({}), undefined);
519
+ });
@@ -15,7 +15,7 @@
15
15
  import type { AppApi, RuntimeContext } from "../context.ts";
16
16
  import { errorMessage } from "../guards.ts";
17
17
  import type { HttpRequest, HttpResponse } from "../host.ts";
18
- import { json, normalizeRoutePath, type Route } from "../router.ts";
18
+ import { html, json, normalizeRoutePath, type Route } from "../router.ts";
19
19
  import { resolveAppPath } from "./datasource.ts";
20
20
  import {
21
21
  collectOperations,
@@ -41,6 +41,48 @@ export interface ApiBinding {
41
41
  base?: string;
42
42
  validateResponses?: "dev" | "always" | "never";
43
43
  eject?: boolean;
44
+ /** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
45
+ * off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
46
+ * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
47
+ docs?: boolean | string;
48
+ }
49
+
50
+ /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
51
+ * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
52
+ * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
53
+ const SWAGGER_UI_VERSION = "5.17.14";
54
+
55
+ /** Escape HTML-significant characters before embedding a value (the app name) in the docs markup. */
56
+ function escapeHtml(s: string): string {
57
+ return s.replace(
58
+ /[&<>"']/g,
59
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c,
60
+ );
61
+ }
62
+
63
+ /** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
64
+ * unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
65
+ * which keeps the docs route decoupled from spec-load failures. */
66
+ function swaggerUiPage(title: string, specUrl: string): string {
67
+ const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
68
+ const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
69
+ return `<!doctype html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="utf-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1">
74
+ <title>${escapeHtml(title)}</title>
75
+ <link rel="stylesheet" href="${css}">
76
+ <style>body{margin:0}</style>
77
+ </head>
78
+ <body>
79
+ <div id="swagger-ui"></div>
80
+ <script src="${bundle}" crossorigin="anonymous"></script>
81
+ <script>
82
+ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
83
+ </script>
84
+ </body>
85
+ </html>`;
44
86
  }
45
87
 
46
88
  /** The `{ params, query, body }` contract a typed operation is generic over. */
@@ -113,6 +155,7 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
113
155
  const base = Reflect.get(raw, "base");
114
156
  const validateResponses = Reflect.get(raw, "validateResponses");
115
157
  const eject = Reflect.get(raw, "eject");
158
+ const docs = Reflect.get(raw, "docs");
116
159
  // Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
117
160
  return {
118
161
  spec: spec.trim(),
@@ -123,9 +166,41 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
123
166
  ? validateResponses
124
167
  : undefined,
125
168
  eject: eject === true,
169
+ // Docs default ON (spec-first apps get Swagger for free); `false` disables; a non-empty
170
+ // string overrides the route. Anything else (including whitespace) falls back to the default.
171
+ docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
126
172
  };
127
173
  }
128
174
 
175
+ /** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
176
+ * serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
177
+ function resolveApiRoutes(binding: ApiBinding): {
178
+ base: string;
179
+ docsEnabled: boolean;
180
+ docsBase: string;
181
+ } {
182
+ const base = normalizeRoutePath(binding.base, "/app/api");
183
+ const docsEnabled = binding.docs !== false;
184
+ // Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
185
+ // of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
186
+ // that owns the `${base}/` prefix (a string `docs` overrides the whole route).
187
+ const docsBase = normalizeRoutePath(
188
+ typeof binding.docs === "string" ? binding.docs : `${base}-docs`,
189
+ `${base}-docs`,
190
+ );
191
+ return { base, docsEnabled, docsBase };
192
+ }
193
+
194
+ /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
195
+ * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
196
+ * the docs without re-deriving the path. */
197
+ export function apiDocsPath(manifest: unknown): string | undefined {
198
+ const binding = readApiBinding(manifest);
199
+ if (!binding) return undefined;
200
+ const { docsEnabled, docsBase } = resolveApiRoutes(binding);
201
+ return docsEnabled ? docsBase : undefined;
202
+ }
203
+
129
204
  /** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
130
205
  * validate. A value that will not coerce is left as-is and fails the type check with a clear
131
206
  * message. The delegate still receives the original string(s). */
@@ -167,9 +242,16 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
167
242
  // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
168
243
  // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
169
244
  // like "app/api" would never match request paths, which always start with "/".
170
- const base = normalizeRoutePath(binding.base, "/app/api");
245
+ //
246
+ // The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
247
+ // this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
248
+ // shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
249
+ // copy of that logic here. The spec JSON is served under the docs path so the operations
250
+ // namespace stays purely operations.
251
+ const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
171
252
  const dir = binding.dir ?? "operations";
172
253
  const surfaceEject = binding.eject === true;
254
+ const specRoutePath = `${docsBase}/openapi.json`;
173
255
 
174
256
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
175
257
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
@@ -445,9 +527,57 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
445
527
  handler: dispatch,
446
528
  };
447
529
 
530
+ // Docs routes (ADR 0058): the Swagger UI page + the OpenAPI JSON it reads. Both are exact GET
531
+ // routes on the `${base}-docs` sibling namespace, so they are wholly independent of the
532
+ // operation dispatcher above.
533
+ const serveSpec = async (): Promise<HttpResponse> => {
534
+ let d: OpenApiDoc;
535
+ try {
536
+ d = await loadDoc();
537
+ } catch (e) {
538
+ return json({ error: errorMessage(e) }, 500);
539
+ }
540
+ // Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
541
+ // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
542
+ // makes the interactive console call the real Urban routes rather than the origin root.
543
+ return json({ ...d, servers: [{ url: base }] });
544
+ };
545
+ const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
546
+ const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specRoutePath));
547
+ // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
548
+ // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
549
+ // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
550
+ // (slash-less) `docsBase` that `apiDocsPath` and every internal link already use.
551
+ const redirectDocsSlash = (): HttpResponse => ({ status: 308, headers: { location: docsBase } });
552
+ const docsRoutes: Route[] = docsEnabled
553
+ ? [
554
+ { method: "GET", path: docsBase, source: `api-docs:${binding.spec}`, handler: serveDocs },
555
+ {
556
+ method: "GET",
557
+ path: `${docsBase}/`,
558
+ source: `api-docs:${binding.spec}`,
559
+ handler: redirectDocsSlash,
560
+ },
561
+ {
562
+ method: "GET",
563
+ path: specRoutePath,
564
+ source: `api-docs:${binding.spec}`,
565
+ handler: serveSpec,
566
+ },
567
+ ]
568
+ : [];
569
+
448
570
  return {
449
571
  name: "api",
450
- routes: [rootRoute, route],
451
- describe: () => ({ api: { spec: binding.spec, base, dir, eject: surfaceEject } }),
572
+ routes: [...docsRoutes, rootRoute, route],
573
+ describe: () => ({
574
+ api: {
575
+ spec: binding.spec,
576
+ base,
577
+ dir,
578
+ eject: surfaceEject,
579
+ docs: docsEnabled ? { ui: docsBase, spec: specRoutePath } : false,
580
+ },
581
+ }),
452
582
  };
453
583
  }
@@ -122,6 +122,40 @@ test("GET /app/runtime.js serves the renderer module", async () => {
122
122
  assert.match(res.body ?? "", /function apiUrl\(u\)/);
123
123
  });
124
124
 
125
+ test("the shell renders an API-docs badge only when an apiDocsPath is provided", async () => {
126
+ const routes = createPagesRoutes(
127
+ { pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
128
+ { db: fakeDb(), engine: fakeEngine().engine, readPage: async () => "{}" },
129
+ );
130
+ const withBadge = (await makeRouter(routes)(req("GET", "/"))).body ?? "";
131
+ assert.match(withBadge, /class="pc-apidocs"/);
132
+ assert.match(withBadge, /href="\/app\/api-docs"/);
133
+ // Opens in a new tab with a hardened rel (no reverse-tabnabbing handle).
134
+ assert.match(withBadge, /rel="noopener noreferrer"/);
135
+
136
+ // No `api` binding → no badge element (the CSS rule is always present in the shell styles).
137
+ const noBadge = (await dispatch("GET", "/")).body ?? "";
138
+ assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
139
+ });
140
+
141
+ test("renderActionForm supports a publishMessage action (message-started processes)", async () => {
142
+ const res = await dispatch("GET", "/app/runtime.js");
143
+ const js = res.body ?? "";
144
+ // The form submit branches on the action kind: publishMessage posts to the message endpoint,
145
+ // deriving the correlation key from the named form field; startProcess keeps its old path.
146
+ assert.match(js, /p\.action\.kind === "publishMessage"/);
147
+ assert.match(js, /variables\[p\.action\.correlationKeyField\]/);
148
+ assert.match(js, /"\/app\/actions\/message"/);
149
+ // The trimmed correlation key is written back as the variable, so the value a
150
+ // worker reads matches the key the message was correlated on.
151
+ assert.match(js, /variables\[p\.action\.correlationKeyField\] = correlationKey/);
152
+ // startProcess remains the default/back-compat branch.
153
+ assert.match(js, /"\/app\/actions\/start\/" \+ encodeURIComponent\(p\.action\.process\)/);
154
+ // A missing action is reported clearly instead of throwing on p.action.process.
155
+ assert.match(js, /if \(!p\.action \|\| !p\.action\.process\)/);
156
+ assert.match(js, /no action configured/);
157
+ });
158
+
125
159
  test("the renderer bridges the console theme over postMessage", async () => {
126
160
  const res = await dispatch("GET", "/app/runtime.js");
127
161
  const js = res.body ?? "";
@@ -20,6 +20,7 @@ import { errorMessage, isRecord } from "../guards.ts";
20
20
  import type { EngineClient } from "../host.ts";
21
21
  import { html, json, type Route } from "../router.ts";
22
22
  import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
23
+ import { apiDocsPath } from "./api.ts";
23
24
  import { quoteIdent } from "./gateway.ts";
24
25
 
25
26
  /** The subset of the datasource gateway the page runtime needs. */
@@ -37,6 +38,12 @@ export interface PagesOptions {
37
38
  rowLimit?: number;
38
39
  /** The injected default datasource name (the alias apps bind to). Default `app`. */
39
40
  sourceName?: string;
41
+ /**
42
+ * The app's Swagger UI route (from the `api` binding, resolved by `apiDocsPath`). When set,
43
+ * the shell renders a persistent "API docs" badge linking to it — so a spec-first app surfaces
44
+ * its interactive docs from its own UI for free. Omitted → no badge (app declares no `api`).
45
+ */
46
+ apiDocsPath?: string;
40
47
  }
41
48
 
42
49
  export interface PagesDeps {
@@ -115,7 +122,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
115
122
  };
116
123
 
117
124
  const routes: Route[] = [];
118
- const shell = html(rendererShell(homePage));
125
+ const shell = html(rendererShell(homePage, opts.apiDocsPath));
119
126
 
120
127
  // ── the renderer shell + module ─────────────────────────────────────────
121
128
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
@@ -372,6 +379,9 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
372
379
  homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
373
380
  rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
374
381
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
382
+ // Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
383
+ // binding (resolved by the api module, so the path never drifts from where docs mount).
384
+ apiDocsPath: apiDocsPath(ctx.manifest),
375
385
  };
376
386
  const sourceName = opts.sourceName ?? "app";
377
387
  const bindings = ctx.manifest.instanceTracking ?? [];
@@ -400,7 +410,13 @@ function escapeAttr(s: string): string {
400
410
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
401
411
  }
402
412
 
403
- function rendererShell(homePage: string): string {
413
+ function rendererShell(homePage: string, apiDocsPath?: string): string {
414
+ // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
415
+ // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
416
+ // tab a handle back to this window (reverse-tabnabbing).
417
+ const apiDocsBadge = apiDocsPath
418
+ ? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
419
+ : "";
404
420
  return `<!doctype html>
405
421
  <html lang="en">
406
422
  <head>
@@ -409,7 +425,7 @@ function rendererShell(homePage: string): string {
409
425
  <title>Urban App</title>
410
426
  <style>${RENDERER_CSS}</style>
411
427
  </head>
412
- <body>
428
+ <body>${apiDocsBadge}
413
429
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
414
430
  <script type="module" src="./app/runtime.js"></script>
415
431
  </body>
@@ -457,6 +473,8 @@ const RENDERER_CSS = `
457
473
  * { box-sizing: border-box; }
458
474
  body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
459
475
  .pc-empty { color:var(--nano-text-faint); }
476
+ .pc-apidocs { position:fixed; top:.75rem; right:.75rem; z-index:10; font-size:.8rem; text-decoration:none; padding:.3rem .6rem; border:1px solid var(--nano-edge); border-radius:999px; background:var(--nano-panel); color:var(--nano-text-muted); }
477
+ .pc-apidocs:hover { color:var(--nano-text); border-color:var(--nano-accent); }
460
478
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
461
479
  .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
462
480
  .pc-body { margin:.5rem 0; }
@@ -704,10 +722,37 @@ function renderActionForm(node) {
704
722
  }
705
723
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
706
724
  try {
707
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
708
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
709
- msg.className = "pc-msg ok";
710
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
725
+ // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
726
+ // publishMessage -> engine.publishMessage, which correlates a message to a message start
727
+ // (or intermediate catch) event. correlationKeyField names the form field that carries
728
+ // the correlation key; the whole form becomes the message variables.
729
+ if (p.action && p.action.kind === "publishMessage") {
730
+ const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
731
+ if (!correlationKey) {
732
+ msg.className = "pc-msg err";
733
+ msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
734
+ return;
735
+ }
736
+ // Send the trimmed key back as the variable too, so the value a worker
737
+ // reads matches the key the message was correlated on (no leading/trailing
738
+ // whitespace divergence between correlationKey and variables[field]).
739
+ variables[p.action.correlationKeyField] = correlationKey;
740
+ await getJSON("/app/actions/message", { method: "POST",
741
+ headers: { "content-type": "application/json" },
742
+ body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
743
+ msg.className = "pc-msg ok";
744
+ msg.textContent = "Message published";
745
+ } else {
746
+ if (!p.action || !p.action.process) {
747
+ msg.className = "pc-msg err";
748
+ msg.textContent = "This form has no action configured";
749
+ return;
750
+ }
751
+ const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
752
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
753
+ msg.className = "pc-msg ok";
754
+ msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
755
+ }
711
756
  for (const input of inputs.values()) input.value = "";
712
757
  document.dispatchEvent(new CustomEvent("pc:refresh"));
713
758
  } catch (e) {
@@ -9,6 +9,12 @@ test("normalizeRoutePath ensures a leading slash and trims trailing slashes", ()
9
9
  assert.equal(normalizeRoutePath(" /chat ", "/chat"), "/chat");
10
10
  });
11
11
 
12
+ test("normalizeRoutePath collapses leading slashes so a //host value stays in-app", () => {
13
+ assert.equal(normalizeRoutePath("//evil.com", "/app/api"), "/evil.com");
14
+ assert.equal(normalizeRoutePath("///app/api", "/app/api"), "/app/api");
15
+ assert.equal(normalizeRoutePath(" //app/api-docs ", "/app/api"), "/app/api-docs");
16
+ });
17
+
12
18
  test("normalizeRoutePath falls back when value is missing or collapses to root", () => {
13
19
  assert.equal(normalizeRoutePath(undefined, "/tasks"), "/tasks");
14
20
  assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
@@ -27,14 +27,17 @@ export function text(body: string, status = 200): HttpResponse {
27
27
  }
28
28
 
29
29
  /**
30
- * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
31
- * slashes. Incoming request paths always start with "/", so a manifest path
32
- * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
33
- * `fallback` when the value is missing or collapses to empty ("/").
30
+ * Normalize a manifest-supplied route path: collapse leading slashes to exactly
31
+ * one, drop trailing slashes. Incoming request paths always start with "/", so a
32
+ * manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
33
+ * multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
34
+ * entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
35
+ * path instead of becoming a protocol-relative URL that navigates off-origin. Falls
36
+ * back to `fallback` when the value is missing or collapses to empty ("/").
34
37
  */
35
38
  export function normalizeRoutePath(value: string | undefined, fallback: string): string {
36
39
  const raw = (value ?? fallback).trim();
37
- const withLead = raw.startsWith("/") ? raw : `/${raw}`;
40
+ const withLead = `/${raw.replace(/^\/+/, "")}`;
38
41
  const noTrail = withLead.replace(/\/+$/, "");
39
42
  return noTrail || fallback;
40
43
  }