@nanobpm/urban 0.76.0 → 0.77.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.
@@ -1,10 +1,22 @@
1
- import type { HttpHandler, HttpResponse } from "./host.ts";
1
+ import type { HttpHandler, HttpRequest, HttpResponse } from "./host.ts";
2
+ /**
3
+ * A route handler that may DECLINE a matched request by returning `undefined`
4
+ * (or a promise of it). The router then treats the route as if it had not matched
5
+ * and continues to the next route (fall-through). This lets a broad prefix route —
6
+ * e.g. the pages surface's nested static serve at `/` — claim only the requests it
7
+ * can actually satisfy (a real, safe asset) and let everything else (`/healthz`, an
8
+ * app's own trigger routes registered *after* it) resolve to their own handlers,
9
+ * instead of a root catch-all silently swallowing them. A handler that returns a
10
+ * concrete `HttpResponse` (including a 404) still short-circuits as before, so this
11
+ * is backwards-compatible with every existing handler (none return `undefined`).
12
+ */
13
+ export type RouteHandler = (req: HttpRequest) => Promise<HttpResponse | undefined> | HttpResponse | undefined;
2
14
  export interface Route {
3
15
  method: string;
4
16
  path: string;
5
17
  /** When true, match any path that starts with `path`. */
6
18
  prefix?: boolean;
7
- handler: HttpHandler;
19
+ handler: RouteHandler;
8
20
  /** For diagnostics / inspect(). */
9
21
  source?: string;
10
22
  }
@@ -43,8 +43,14 @@ function matches(route, req) {
43
43
  export function makeRouter(routes) {
44
44
  return async (req) => {
45
45
  for (const route of routes) {
46
- if (matches(route, req))
47
- return route.handler(req);
46
+ if (matches(route, req)) {
47
+ // A handler may DECLINE by returning `undefined` (see RouteHandler): treat the
48
+ // route as unmatched and keep looking, so a broad prefix serve can't shadow a
49
+ // later exact route (`/healthz`, a trigger) it didn't actually satisfy.
50
+ const res = await route.handler(req);
51
+ if (res !== undefined)
52
+ return res;
53
+ }
48
54
  }
49
55
  return text("not found", 404);
50
56
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.76.0",
3
+ "version": "0.77.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",
@@ -2385,32 +2385,58 @@ function renderDataGrid(node) {
2385
2385
  }
2386
2386
 
2387
2387
  // A navigation node — a horizontal menu bar (variant "bar", default) or a
2388
- // vertical side rail (variant "rail"). Each item links either to another page
2389
- // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
2390
- // current page) or to an external URL ({ label, href } -> a hardened new-tab
2391
- // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
2392
- // lists every page from the /app/pages index, using each page's title. An item
2393
- // may also carry a `badge` ({ source, table, filter, tone, refreshMs,
2394
- // hideWhenZero }) that renders a live count pill from the /app/data gateway.
2388
+ // vertical side rail (variant "rail"). Each item links to one of three targets,
2389
+ // in precedence order:
2390
+ // 1. { label, page } → an in-app #/<page> hash link within THIS (pages)
2391
+ // surface, highlighted when it's the current page.
2392
+ // 2. { label, path } → a same-origin, same-tab link to another mounted
2393
+ // surface by relative path (e.g. "/tasks" → the taskInbox surface). The
2394
+ // path is resolved against the app's mount root via baseRelativeUrl, so a
2395
+ // root-absolute "/tasks" survives the Nano console's path-prefixed App-View
2396
+ // proxy (/console/app-view/<name>/tasks) instead of escaping to the console
2397
+ // origin. Only same-origin values are honoured here — a scheme
2398
+ // (http(s)/javascript:/…) or protocol-relative "//host" is rejected (""),
2399
+ // falling through to the {href} case, so `path` can never smuggle an
2400
+ // external or executable-scheme target into a same-tab link. This is the
2401
+ // only link form that composes multi-surface apps into a coherent,
2402
+ // proxy-safe navigable whole (nanobpm/nano-ide#436).
2403
+ // 3. { label, href } → a hardened external new-tab link; only http(s) is
2404
+ // honoured.
2405
+ // With items:"auto" (or omitted) the nav lists every page from the /app/pages
2406
+ // index, using each page's title. An item may also carry a `badge` ({ source,
2407
+ // table, filter, tone, refreshMs, hideWhenZero }) that renders a live count pill
2408
+ // from the /app/data gateway.
2395
2409
  /** @param {any} item
2396
2410
  * @returns {HTMLAnchorElement|null}
2397
2411
  */
2398
2412
  function navLink(item) {
2399
2413
  const isPage = safePageId(item.page);
2400
- const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
2401
- if (!isPage && !isExt) return null;
2414
+ // A same-origin relative path to another mounted surface (e.g. "/tasks"),
2415
+ // rebased onto the app mount root so it survives the console proxy prefix.
2416
+ const rel = isPage ? "" : sameOriginPath(item.path);
2417
+ const isRel = !isPage && rel !== "";
2418
+ const isExt = !isPage && !isRel && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
2419
+ if (!isPage && !isRel && !isExt) return null;
2402
2420
  /** @type {Record<string, any>} */
2403
2421
  const attrs = { class: "pc-nav-link" };
2404
2422
  if (isPage) {
2405
2423
  attrs.href = "#/" + encodeURIComponent(item.page);
2406
2424
  if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
2425
+ } else if (isRel) {
2426
+ // Same-origin, same-tab: no target=_blank — this is in-app cross-surface
2427
+ // navigation, not an external hand-off.
2428
+ attrs.href = rel;
2407
2429
  } else {
2408
2430
  attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
2409
2431
  }
2410
2432
  /** @type {Array<Node|string>} */
2411
2433
  const kids = [];
2412
2434
  if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
2413
- const labelText = String(item.label != null ? item.label : (item.page || item.href));
2435
+ // Default the label from the target actually used, not a fixed page>path>href
2436
+ // fallback: when `path` is rejected and we fell through to `href`, the label
2437
+ // must reflect the href we navigate to, never the discarded `path`.
2438
+ const defaultLabel = isPage ? item.page : (isRel ? item.path : item.href);
2439
+ const labelText = String(item.label != null ? item.label : defaultLabel);
2414
2440
  kids.push(el("span", { class: "pc-nav-label" }, labelText));
2415
2441
  const link = el("a", attrs, ...kids);
2416
2442
  // A nav item may carry a live count badge sourced from a datasource (e.g. the
@@ -2560,6 +2586,31 @@ function baseRelativeUrl(url) {
2560
2586
  return cleaned; // already relative
2561
2587
  }
2562
2588
 
2589
+ // Resolve a same-origin, in-app relative path (e.g. a nav item's { path: "/tasks" }
2590
+ // targeting another mounted surface) onto the app's mount root, so it survives the
2591
+ // console App-View proxy prefix — the SAME rebasing rule as baseRelativeUrl (the
2592
+ // single source of truth for "strip one leading slash to rebase onto the mount
2593
+ // root"). Unlike baseRelativeUrl (whose output is an <iframe> src that may legally
2594
+ // be an http(s) URL), a cross-surface nav link must stay SAME-ORIGIN and same-tab:
2595
+ // a scheme (http(s):, and — defence in depth — javascript:/data:/… ) or a
2596
+ // protocol-relative "//host" is NOT an in-app path, so it is rejected ("") and the
2597
+ // caller falls through to the hardened new-tab {href} handling. Whitespace is
2598
+ // stripped/trimmed FIRST (browsers strip ASCII tab/newline before parsing a URL, so
2599
+ // `/ta\tsks` must be judged on its cleaned form) so the scheme gate can't be
2600
+ // bypassed by embedded whitespace.
2601
+ /** @param {any} path */
2602
+ function sameOriginPath(path) {
2603
+ if (typeof path !== "string") return "";
2604
+ // Browsers normalize backslashes to `/` in URLs, so `\\evil.example/tasks` (or
2605
+ // `/\evil.example`) is parsed as protocol-relative `//evil.example/…`. Fold `\` → `/`
2606
+ // before the scheme/`//` gates so a backslash can't smuggle a cross-origin target past them.
2607
+ const cleaned = path.replace(/[\t\n\r]/g, "").replace(/\\/g, "/").trim();
2608
+ if (cleaned === "") return "";
2609
+ if (/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return ""; // any scheme → not a same-origin in-app path
2610
+ if (cleaned.startsWith("//")) return ""; // protocol-relative → different origin
2611
+ return baseRelativeUrl(cleaned); // rebase root-absolute onto the mount root (shared rule)
2612
+ }
2613
+
2563
2614
  // `appView` (ADR 0057 / #416): mount an embedded App View — an <iframe> hosting a
2564
2615
  // self-contained view document (e.g. the agent cockpit's embed.html) served
2565
2616
  // alongside the page. This is the runtime counterpart of the console's App-View
@@ -2798,4 +2849,4 @@ function boot() {
2798
2849
  // renderer functions / RENDERERS registry can be imported directly.
2799
2850
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2800
2851
 
2801
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
2852
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
@@ -7,7 +7,7 @@
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import { PAGE_NODE_TYPES } from "../core/page-nodes.ts";
10
- import { RENDERERS, renderText, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
10
+ import { RENDERERS, renderText, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
11
11
 
12
12
  // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
13
  // Just enough of the Element/Document surface that el()/renderText touch:
@@ -348,6 +348,110 @@ test("#338: navLink badge degrades to no pill (never a broken nav) when the fetc
348
348
  assert.equal(pill.textContent, "");
349
349
  });
350
350
 
351
+ // ── #436: same-origin cross-surface nav (a { path } item) ───────────────────
352
+ // A pages-surface nav item can now point at another mounted surface by a relative
353
+ // same-origin path (e.g. "/tasks" → the taskInbox surface): an in-page, same-tab
354
+ // link, rebased onto the app mount root so it survives the console App-View proxy
355
+ // prefix. This is distinct from { page } (an in-app #/<page> hash) and { href }
356
+ // (an external new-tab http(s) link).
357
+
358
+ test("#436: navLink({ path }) renders a same-origin, same-tab link rebased onto the mount root", (t) => {
359
+ t.after(installFakeDom());
360
+ const link = navLink({ label: "Tasks", path: "/tasks" });
361
+ assert.ok(link, "expected a nav link for a { path } item");
362
+ assert.equal(link.tagName, "A");
363
+ // Root-absolute "/tasks" is rebased to a document-relative "tasks" so it inherits
364
+ // the mount root (and thus the console proxy prefix) rather than escaping to origin.
365
+ assert.equal(link.getAttribute("href"), "tasks");
366
+ // Same-tab, same-origin: NOT an external hand-off — no new-tab / noopener chrome.
367
+ assert.equal(link.getAttribute("target"), null, "cross-surface nav stays in the same tab");
368
+ assert.equal(link.getAttribute("rel"), null, "no rel=noopener on a same-origin link");
369
+ const label = findByClass(link, "pc-nav-label");
370
+ assert.ok(label && label.textContent === "Tasks");
371
+ });
372
+
373
+ test("#436: navLink({ path }) falls back to the path as its label when none is given", (t) => {
374
+ t.after(installFakeDom());
375
+ const link = navLink({ path: "/tasks" });
376
+ assert.ok(link);
377
+ const label = findByClass(link, "pc-nav-label");
378
+ assert.ok(label && label.textContent === "/tasks", "label defaults to the raw path");
379
+ });
380
+
381
+ test("#436: a { page } item still wins over a { path } (in-app hash route has precedence)", (t) => {
382
+ t.after(installFakeDom());
383
+ const link = navLink({ label: "Home", page: "home", path: "/tasks" });
384
+ assert.ok(link);
385
+ assert.equal(link.getAttribute("href"), "#/home", "page hash route takes precedence over path");
386
+ assert.equal(link.getAttribute("target"), null);
387
+ });
388
+
389
+ test("#436: a same-origin { path } wins over an external { href } (in-app link preferred)", (t) => {
390
+ t.after(installFakeDom());
391
+ const link = navLink({ label: "Tasks", path: "/tasks", href: "https://example.test/tasks" });
392
+ assert.ok(link);
393
+ assert.equal(link.getAttribute("href"), "tasks", "the same-origin path is used, not the external href");
394
+ assert.equal(link.getAttribute("target"), null, "so it stays same-tab, not a new-tab external link");
395
+ });
396
+
397
+ test("#436: a scheme/protocol-relative { path } is rejected and falls through to the { href } handling", (t) => {
398
+ t.after(installFakeDom());
399
+ // A hostile/mistaken path carrying a scheme must NOT become a same-tab link — it
400
+ // is rejected as not-same-origin and the item falls through to the hardened
401
+ // new-tab external-href branch.
402
+ const link = navLink({ label: "Evil", path: "javascript:alert(1)", href: "https://example.test/ok" });
403
+ assert.ok(link);
404
+ assert.equal(link.getAttribute("href"), "https://example.test/ok");
405
+ assert.equal(link.getAttribute("target"), "_blank");
406
+ assert.equal(link.getAttribute("rel"), "noopener noreferrer");
407
+ });
408
+
409
+ test("#436: with no label and a rejected { path }, the default label matches the { href } actually used", (t) => {
410
+ t.after(installFakeDom());
411
+ // No explicit label: the path is rejected (unsafe scheme) and we fall through to
412
+ // the external href. The default label MUST reflect the href we navigate to, never
413
+ // the discarded path — otherwise the visible text lies about the link target.
414
+ const link = navLink({ path: "javascript:alert(1)", href: "https://example.test/ok" });
415
+ assert.ok(link);
416
+ assert.equal(link.getAttribute("href"), "https://example.test/ok");
417
+ const label = findByClass(link, "pc-nav-label");
418
+ assert.ok(label && label.textContent === "https://example.test/ok", "label defaults to the href actually used, not the rejected path");
419
+ });
420
+
421
+ test("#436: a { path } item with only an unsafe scheme and no href renders nothing", (t) => {
422
+ t.after(installFakeDom());
423
+ assert.equal(navLink({ label: "Evil", path: "javascript:alert(1)" }), null);
424
+ assert.equal(navLink({ label: "Ext", path: "//evil.example/tasks" }), null);
425
+ });
426
+
427
+ test("#436: sameOriginPath rebases in-app paths and rejects non-same-origin targets", () => {
428
+ // Root-absolute → rebased onto the mount root (survives the console proxy prefix).
429
+ assert.equal(sameOriginPath("/tasks"), "tasks");
430
+ assert.equal(sameOriginPath("/tasks/inbox"), "tasks/inbox");
431
+ // Already document-relative → left as-is.
432
+ assert.equal(sameOriginPath("tasks"), "tasks");
433
+ assert.equal(sameOriginPath("./tasks"), "./tasks");
434
+ // Embedded ASCII tab/newline is stripped before the scheme gate (browsers strip
435
+ // it before parsing), so `java\tscript:` can't sneak past as a "relative" path.
436
+ assert.equal(sameOriginPath("java\tscript:alert(1)"), "");
437
+ // Any scheme / protocol-relative host is not a same-origin in-app path.
438
+ assert.equal(sameOriginPath("https://example.test/tasks"), "");
439
+ assert.equal(sameOriginPath("http://example.test/tasks"), "");
440
+ assert.equal(sameOriginPath("javascript:alert(1)"), "");
441
+ assert.equal(sameOriginPath("data:text/html,x"), "");
442
+ assert.equal(sameOriginPath("//evil.example/tasks"), "");
443
+ // Backslashes are folded to `/` before the gates (browsers normalize them), so a
444
+ // `\\host` or `/\host` value can't smuggle a protocol-relative cross-origin target past.
445
+ assert.equal(sameOriginPath("\\\\evil.example/tasks"), "");
446
+ assert.equal(sameOriginPath("/\\evil.example/tasks"), "");
447
+ assert.equal(sameOriginPath("\\/evil.example/tasks"), "");
448
+ // Non-strings / blanks → "".
449
+ assert.equal(sameOriginPath(""), "");
450
+ assert.equal(sameOriginPath(" "), "");
451
+ assert.equal(sameOriginPath(null), "");
452
+ assert.equal(sameOriginPath(42), "");
453
+ });
454
+
351
455
  test("#338: applyNavBadge honours hideWhenZero and mirrors the count into the accessible name", (t) => {
352
456
  const restore = installFakeDom();
353
457
  t.after(restore);
@@ -5,7 +5,8 @@ import { test } from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { mountAgent } from "./agent.ts";
7
7
  import type { RuntimeContext } from "../context.ts";
8
- import type { EngineClient, HostContext, HttpRequest, HttpResponse } from "../host.ts";
8
+ import type { EngineClient, HostContext, HttpResponse } from "../host.ts";
9
+ import type { Route } from "../router.ts";
9
10
 
10
11
  const fakeEngine: EngineClient = {
11
12
  deployResources: async () => ({ deployed: 0 }),
@@ -44,14 +45,16 @@ function ctxWithFiles(files: Record<string, string>): RuntimeContext {
44
45
  return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App" }, host, engine: fakeEngine };
45
46
  }
46
47
 
47
- async function call(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<HttpResponse> {
48
- return route.handler({
48
+ async function call(route: Route): Promise<HttpResponse> {
49
+ const res = await route.handler({
49
50
  method: "GET",
50
51
  path: "/",
51
52
  query: new URLSearchParams(),
52
53
  headers: new Headers(),
53
54
  text: async () => "",
54
55
  });
56
+ assert.ok(res, "handler should not decline");
57
+ return res;
55
58
  }
56
59
 
57
60
  const routeAt = (ctx: RuntimeContext, path: string) => {
@@ -1823,3 +1823,130 @@ test("the static asset routes do not shadow the app's own routes or /healthz", a
1823
1823
  const home = await router(req("GET", "/"));
1824
1824
  assert.match(home.body ?? "", /data-home="home"/);
1825
1825
  });
1826
+
1827
+ // ── nested (multi-appView) sidecar serving (#442) ──────────────────────────
1828
+ // An app with two-or-more appViews ships each view's sidecar under its own subdir
1829
+ // (`./cockpit/embed.html`, `./board/embed.html`); #420's flat 4-name serve 404'd every
1830
+ // one. These pin the generalized nested prefix serve of `pages/<subpath>` (and that it
1831
+ // still declines — never a root catch-all — so `/healthz` / triggers keep resolving).
1832
+
1833
+ test("GET /<subdir>/embed.html serves the nested appView sidecar", async () => {
1834
+ const router = buildWithAssets({
1835
+ "pages/cockpit/embed.html": "<!doctype html><title>cockpit</title>",
1836
+ "pages/board/embed.html": "<!doctype html><title>board</title>",
1837
+ });
1838
+ const cockpit = await router(req("GET", "/cockpit/embed.html"));
1839
+ assert.equal(cockpit.status, 200);
1840
+ assert.match(cockpit.headers?.["content-type"] ?? "", /text\/html/);
1841
+ assert.match(cockpit.body ?? "", /<title>cockpit<\/title>/);
1842
+ const board = await router(req("GET", "/board/embed.html"));
1843
+ assert.equal(board.status, 200);
1844
+ assert.match(board.body ?? "", /<title>board<\/title>/);
1845
+ });
1846
+
1847
+ test("all nested subdir sidecar assets are served with correct content types", async () => {
1848
+ const router = buildWithAssets({
1849
+ "pages/cockpit/embed.html": "<html>embed</html>",
1850
+ "pages/cockpit/standalone.html": "<html>standalone</html>",
1851
+ "pages/cockpit/mount.js": "export const mountCockpit = () => {};",
1852
+ "pages/cockpit/cockpit.css": "body{margin:0}",
1853
+ "pages/delivery-graphs/embed.html": "<html>dg</html>",
1854
+ });
1855
+ const cases: [string, RegExp][] = [
1856
+ ["/cockpit/embed.html", /text\/html/],
1857
+ ["/cockpit/standalone.html", /text\/html/],
1858
+ ["/cockpit/mount.js", /text\/javascript/],
1859
+ ["/cockpit/cockpit.css", /text\/css/],
1860
+ // A hyphenated multi-appView page id (the merlin repro) resolves too.
1861
+ ["/delivery-graphs/embed.html", /text\/html/],
1862
+ ];
1863
+ for (const [path, ct] of cases) {
1864
+ const res = await router(req("GET", path));
1865
+ assert.equal(res.status, 200, `${path} should 200`);
1866
+ assert.match(res.headers?.["content-type"] ?? "", ct, `${path} content-type`);
1867
+ }
1868
+ });
1869
+
1870
+ test("the flat single-appView sidecar back-compat still serves (#420) alongside nested", async () => {
1871
+ // The root `pages/embed.html` case #420 shipped must keep working: an app with exactly
1872
+ // one appView (no subdir) is unaffected by the nested generalization.
1873
+ const router = buildWithAssets({
1874
+ "pages/embed.html": "<html>flat</html>",
1875
+ "pages/cockpit/embed.html": "<html>nested</html>",
1876
+ });
1877
+ const flat = await router(req("GET", "/embed.html"));
1878
+ assert.equal(flat.status, 200);
1879
+ assert.match(flat.body ?? "", /flat/);
1880
+ const nested = await router(req("GET", "/cockpit/embed.html"));
1881
+ assert.equal(nested.status, 200);
1882
+ assert.match(nested.body ?? "", /nested/);
1883
+ });
1884
+
1885
+ test("a missing nested sidecar 404s instead of throwing", async () => {
1886
+ const router = buildWithAssets({ "pages/cockpit/embed.html": "<html>ok</html>" });
1887
+ const res = await router(req("GET", "/board/embed.html"));
1888
+ assert.equal(res.status, 404);
1889
+ });
1890
+
1891
+ test("a nested-serve path traversal attempt is rejected (never reaches the filesystem)", async () => {
1892
+ // The `pages/` tree reuses the SAME sub-path guard as `/dist/`: any `..` / absolute /
1893
+ // percent-escaped / backslash / doubled-slash segment must be rejected before a read.
1894
+ let readAttempts = 0;
1895
+ const { engine } = fakeEngine();
1896
+ const router = makeRouter(
1897
+ createPagesRoutes(
1898
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
1899
+ {
1900
+ db: fakeDb(),
1901
+ engine,
1902
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
1903
+ readAsset: async (path: string) => {
1904
+ readAttempts++;
1905
+ return `bytes of ${path}`;
1906
+ },
1907
+ listPages: async () => ["home"],
1908
+ },
1909
+ ),
1910
+ );
1911
+ for (const evil of ["/cockpit/../secret.json", "/a/../../secret", "/cockpit/%2e%2e/secret", "/cockpit//etc"]) {
1912
+ const res = await router(req("GET", evil));
1913
+ assert.equal(res.status, 404, `${evil} must 404`);
1914
+ }
1915
+ assert.equal(readAttempts, 0, "no traversal path may reach the asset reader");
1916
+ });
1917
+
1918
+ test("the nested serve declines (does not shadow) a same-path trigger route registered after it", async () => {
1919
+ // The nested serve is a `/`-prefix route registered before the triggers surface. A miss
1920
+ // must DECLINE (fall through) so a trigger at `/webhooks/deploy` still resolves, rather
1921
+ // than being masked by the pages tree's 404. A root catch-all would silently swallow it.
1922
+ const routes = createPagesRoutes(
1923
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
1924
+ {
1925
+ db: fakeDb(),
1926
+ engine: fakeEngine().engine,
1927
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
1928
+ readAsset: async (path: string) => {
1929
+ // Only the real nested sidecar exists; every other path is a miss.
1930
+ if (path === "pages/cockpit/embed.html") return "<html>cockpit</html>";
1931
+ throw new Error(`no such file: ${path}`);
1932
+ },
1933
+ listPages: async () => ["home"],
1934
+ },
1935
+ );
1936
+ const router = makeRouter([
1937
+ ...routes,
1938
+ {
1939
+ method: "GET",
1940
+ path: "/webhooks/deploy",
1941
+ handler: () => ({ status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify({ queued: true }) }),
1942
+ },
1943
+ ]);
1944
+ // The real nested sidecar is served by the pages tree.
1945
+ const sidecar = await router(req("GET", "/cockpit/embed.html"));
1946
+ assert.equal(sidecar.status, 200);
1947
+ assert.match(sidecar.body ?? "", /cockpit/);
1948
+ // The multi-segment trigger path is NOT shadowed — the nested serve declines the miss.
1949
+ const hook = await router(req("GET", "/webhooks/deploy"));
1950
+ assert.equal(hook.status, 202);
1951
+ assert.deepEqual(JSON.parse(hook.body ?? "{}"), { queued: true });
1952
+ });
@@ -11,7 +11,8 @@
11
11
  // GET / (+ /app/runtime.js) → the schema-driven browser renderer
12
12
  // GET /app/pages/<id> → the page's page.json
13
13
  // GET /app/data/<source>/<table>[?where&order] → rows (filtered/ordered, whitelisted)
14
- // GET /{embed,standalone}.html /mount.js /cockpit.css → appView sidecars (from pagesDir, #420)
14
+ // GET /{embed,standalone}.html /mount.js /cockpit.css → flat appView sidecars (from pagesDir, #420)
15
+ // GET /<subdir>/<asset> → nested appView sidecars (pages/<subdir>/…, #442)
15
16
  // GET /dist/<path> → the app dist/ bundle the sidecars reference
16
17
  // POST /app/actions/start/<process> → engine.createInstance
17
18
  // POST /app/actions/cancel → engine.cancelInstance
@@ -107,7 +108,10 @@ function javascript(
107
108
  // document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
108
109
  // `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
109
110
  // iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
110
- // the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. The `dist/` tree the
111
+ // the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. An app that ships
112
+ // MORE than one `appView` gives each its own SUBDIR sidecar (`./cockpit/embed.html`,
113
+ // `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
114
+ // this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
111
115
  // import-map references is served separately under the `/dist/` prefix below.
112
116
  const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"] as const;
113
117
 
@@ -541,6 +545,44 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
541
545
  },
542
546
  });
543
547
 
548
+ // ── nested (multi-appView) sidecars (#442) ──────────────────────────────
549
+ // An app with more than one `appView` cannot share the four FLAT sidecar names above:
550
+ // each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
551
+ // `./cockpit/embed.html`, `pages/board.page.json` → `./board/embed.html`. The renderer's
552
+ // `baseRelativeUrl(p.embed)` rebases those onto the mount root, so the browser fetches
553
+ // `/cockpit/embed.html`, `/board/mount.js`, … — every one of which #420's flat 4-name
554
+ // serve 404'd. Serve the `pagesDir` as a TREE, mapping `/<subpath>` onto
555
+ // `pagesDir/<subpath>` with the SAME traversal guard as the `/dist/` route (mirroring it).
556
+ // It is a `/`-prefix route, so to avoid a root catch-all silently swallowing `/healthz`,
557
+ // the app's own trigger routes, or any other route, the handler DECLINES (returns
558
+ // undefined → the router falls through to the next route, see `RouteHandler`) whenever it
559
+ // cannot serve a real nested asset:
560
+ // • a bare single-segment path (`/healthz`, the flat sidecars) — owned by its own exact
561
+ // route / the runtime's liveness route, never this tree;
562
+ // • an unsafe subpath (`..`/absolute/percent-escaped/backslash) — traversal-rejected
563
+ // before it reaches the filesystem;
564
+ // • a miss (no such file) — so a same-path route registered AFTER the pages surface (a
565
+ // trigger, the appended `/healthz`) still resolves instead of being masked by a 404.
566
+ // Registered LAST among the pages routes so every exact/prefix pages route above wins first.
567
+ routes.push({
568
+ method: "GET",
569
+ path: "/",
570
+ prefix: true,
571
+ source: "surface:pages",
572
+ handler: async (req) => {
573
+ const rest = req.path.slice(1); // drop the leading "/"
574
+ // Only a SUBDIR asset (has a `/`) belongs to this tree; a single-segment path is left
575
+ // to its own exact route / the appended `/healthz` liveness route.
576
+ if (!rest.includes("/")) return undefined;
577
+ const safe = safeAssetSubpath(rest);
578
+ if (safe === null) return undefined; // traversal/escape → decline (never touch the FS)
579
+ const res = await serveAsset(readAsset, `${pagesDir}/${safe}`);
580
+ // A miss declines rather than 404s, so a later same-path route (e.g. a trigger) can
581
+ // still claim it; the router's own default 404 covers a genuinely absent asset.
582
+ return res.status === 404 ? undefined : res;
583
+ },
584
+ });
585
+
544
586
  return routes;
545
587
  }
546
588