@nanobpm/urban 0.72.2 → 0.74.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.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Page-node types this runtime renders that are NOT (yet) in the canonical schema.
3
+ *
4
+ * When a token here lands in `@nanobpm/nano-app-schema`'s `PAGE_NODE_TYPES` and this
5
+ * package's dep is bumped, drop it from this list (and, once the list is empty, delete
6
+ * this module and re-export `PAGE_NODE_TYPES`/`PageNodeType` straight from the schema).
7
+ */
8
+ export declare const LOCAL_PAGE_NODE_TYPES: readonly ["appView"];
9
+ /** The full set of page-node types the browser renderer draws: schema ∪ local bridge. */
10
+ export declare const PAGE_NODE_TYPES: readonly ["text", "nav", "actionForm", "dataGrid", "prose", "button", "appView"];
11
+ /** A node type the composed-page renderer can draw — one of {@link PAGE_NODE_TYPES}. */
12
+ export type PageNodeType = (typeof PAGE_NODE_TYPES)[number];
@@ -0,0 +1,32 @@
1
+ // The page-node type registry the browser renderer draws — the schema's canonical
2
+ // set, extended locally with the node types this runtime ships ahead of the schema.
3
+ //
4
+ // The App page-node contract is owned by @nanobpm/nano-app-schema (ADR 0027): its
5
+ // `PAGE_NODE_TYPES` is the canonical registry the Console's Page Composer binds to.
6
+ // This runtime's browser renderer must draw exactly that set — a schema type with
7
+ // no renderer (or a renderer with no schema type) renders an accepted node as a
8
+ // blank <div>, the silent failure #416 fixed for `appView`.
9
+ //
10
+ // `appView` (ADR 0057 — an embedded App View iframe) ships in this runtime *before*
11
+ // it lands in the published schema. Rather than silently diverge, we bridge it here
12
+ // — the same "mirror locally until the canonical schema folds it in, then delete the
13
+ // bridge and re-export" pattern used for `network` / `terminalStatuses` in
14
+ // `manifest.ts` (No Drift Surfaces). This module is the single source of truth for
15
+ // the extended set: the browser renderer's `RENDERERS satisfies Record<PageNodeType,
16
+ // Renderer>` lock and the runtime drift test both consume `PAGE_NODE_TYPES` from
17
+ // here, so the renderer registry and the type union cannot drift.
18
+ import { PAGE_NODE_TYPES as SCHEMA_PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
19
+ /**
20
+ * Page-node types this runtime renders that are NOT (yet) in the canonical schema.
21
+ *
22
+ * When a token here lands in `@nanobpm/nano-app-schema`'s `PAGE_NODE_TYPES` and this
23
+ * package's dep is bumped, drop it from this list (and, once the list is empty, delete
24
+ * this module and re-export `PAGE_NODE_TYPES`/`PageNodeType` straight from the schema).
25
+ */
26
+ export const LOCAL_PAGE_NODE_TYPES = [
27
+ // #416 / ADR 0057: an embedded App View — an <iframe> hosting a self-contained view
28
+ // document (e.g. the agent cockpit's embed.html) served alongside the page.
29
+ "appView",
30
+ ];
31
+ /** The full set of page-node types the browser renderer draws: schema ∪ local bridge. */
32
+ export const PAGE_NODE_TYPES = [...SCHEMA_PAGE_NODE_TYPES, ...LOCAL_PAGE_NODE_TYPES];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.72.2",
3
+ "version": "0.74.0",
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",
@@ -16,7 +16,7 @@
16
16
  // That lets the renderer functions and the RENDERERS registry be imported and
17
17
  // unit-tested with a fake DOM, and lets the string-generation build load it.
18
18
 
19
- /** @typedef {import("@nanobpm/nano-app-schema").PageNodeType} PageNodeType */
19
+ /** @typedef {import("../core/page-nodes.ts").PageNodeType} PageNodeType */
20
20
  /**
21
21
  * A page node from a `*.page.json` document. `props` is an open, author-supplied
22
22
  * bag whose shape is owned/validated by `@nanobpm/nano-app-schema`, so it is
@@ -2532,16 +2532,108 @@ function renderNav(node) {
2532
2532
  return nav;
2533
2533
  }
2534
2534
 
2535
+ // A page-relative URL that resolves correctly whether the app is served at the
2536
+ // origin root (CLI on :3000) or under the Nano console's path-prefixed App-View
2537
+ // proxy (/console/app-view/<name>/). Same reasoning as the shell's `badgeHref`
2538
+ // and mount.js's reportUrl handling: a root-absolute "/x" would escape the proxy
2539
+ // prefix and hit the CONSOLE origin, so a single leading slash is stripped to
2540
+ // rebase onto the mount root; an already document-relative ("./x", "x") or an
2541
+ // absolute ("http(s)://…") / protocol-relative ("//host") URL is left untouched.
2542
+ // The result becomes an <iframe> src, so a scheme URL is only honoured when it is
2543
+ // http/https: a hostile/malformed page doc could otherwise smuggle an executable
2544
+ // `javascript:`/`data:`/`vbscript:` src, so any other scheme is rejected ("" →
2545
+ // caller omits the attribute). Input is trimmed and ASCII tab/newline stripped
2546
+ // FIRST (browsers strip those before parsing a URL, so `java\tscript:` would still
2547
+ // execute) so scheme detection cannot be bypassed by embedded whitespace.
2548
+ /** @param {string} url */
2549
+ function baseRelativeUrl(url) {
2550
+ if (typeof url !== "string") return "";
2551
+ const cleaned = url.replace(/[\t\n\r]/g, "").trim();
2552
+ if (cleaned === "") return "";
2553
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);
2554
+ if (scheme) {
2555
+ const proto = scheme[1].toLowerCase();
2556
+ return proto === "http" || proto === "https" ? cleaned : ""; // reject javascript:/data:/vbscript:/file:/…
2557
+ }
2558
+ if (cleaned.startsWith("//")) return cleaned; // protocol-relative
2559
+ if (cleaned.startsWith("/")) return cleaned.slice(1); // root-absolute → document-relative (rebase onto mount root)
2560
+ return cleaned; // already relative
2561
+ }
2562
+
2563
+ // `appView` (ADR 0057 / #416): mount an embedded App View — an <iframe> hosting a
2564
+ // self-contained view document (e.g. the agent cockpit's embed.html) served
2565
+ // alongside the page. This is the runtime counterpart of the console's App-View
2566
+ // surface: the SAME embed document renders here (in the app's own UI) and, when the
2567
+ // console embeds the app, there. Before this renderer existed an `appView` node fell
2568
+ // through to a blank <div> (RENDERERS[n.type] || (() => el("div"))), so an entire
2569
+ // embedded view — the live agent cockpit — was SILENTLY invisible with no error.
2570
+ /** @param {PageNode} node */
2571
+ function renderAppView(node) {
2572
+ const p = node.props || {};
2573
+ const title = p.title != null ? String(p.title) : "";
2574
+ const fill = p.fill === true;
2575
+ const src = baseRelativeUrl(typeof p.embed === "string" ? p.embed : "");
2576
+
2577
+ const frame = /** @type {HTMLIFrameElement} */ (
2578
+ el("iframe", {
2579
+ class: "pc-appview-frame",
2580
+ // Base-relative so the embed resolves under both the app root and the console
2581
+ // App-View proxy prefix (see baseRelativeUrl). Omitted (about:blank) when no
2582
+ // usable `embed` is declared — an empty `src=""` resolves to the current
2583
+ // document URL in browsers, risking a recursive self-embed. The node still
2584
+ // renders its chrome rather than throwing.
2585
+ src: src || undefined,
2586
+ title: title || "App view",
2587
+ loading: "lazy",
2588
+ referrerpolicy: "same-origin",
2589
+ })
2590
+ );
2591
+
2592
+ // Forward the App-View endpoint config to the embed's own window (ADR 0057:
2593
+ // embed.html reads `window.__NANO_APP_VIEW__`). The console injects this when IT
2594
+ // hosts the embed; here — the same-origin app render — mount.js already defaults
2595
+ // every endpoint to the app's own origin, so the cockpit is fully functional with
2596
+ // nothing injected. We seed the frame's window on load only to thread through
2597
+ // explicit per-node overrides (props.reportUrl / relayUrl / transcriptsUrl), so a
2598
+ // page can point the embed at a non-default endpoint without editing the embed
2599
+ // document. Best-effort and guarded: a cross-origin embed is left to its defaults.
2600
+ /** @type {Record<string, string>} */
2601
+ const overrides = {};
2602
+ if (typeof p.reportUrl === "string") overrides.reportUrl = p.reportUrl;
2603
+ if (typeof p.relayUrl === "string") overrides.relayUrl = p.relayUrl;
2604
+ if (typeof p.transcriptsUrl === "string") overrides.transcriptsUrl = p.transcriptsUrl;
2605
+ frame.addEventListener("load", () => {
2606
+ try {
2607
+ const w = /** @type {any} */ (frame.contentWindow);
2608
+ if (w) {
2609
+ w.__NANO_APP_VIEW__ = {
2610
+ ...(w.__NANO_APP_VIEW__ || {}),
2611
+ host: (typeof location !== "undefined" && location.origin) || "",
2612
+ ...overrides,
2613
+ };
2614
+ }
2615
+ } catch (_e) {
2616
+ // Cross-origin embed: we cannot (and need not) touch its window.
2617
+ }
2618
+ });
2619
+
2620
+ const section = el("section", { class: "pc-appview" + (fill ? " pc-appview-fill" : "") });
2621
+ if (title) section.append(el("div", { class: "pc-appview-title" }, title));
2622
+ section.append(frame);
2623
+ return section;
2624
+ }
2625
+
2535
2626
  // The browser renderer's dispatch table: the runtime source of truth for which
2536
2627
  // page-node types can be drawn. `@satisfies Record<PageNodeType, Renderer>` locks
2537
- // it, at compile time, to `@nanobpm/nano-app-schema`'s PAGE_NODE_TYPES — the same
2538
- // registry the Console's Page Composer is bound to. Adding a schema node type
2539
- // without a renderer here (or vice-versa) is now a type error, so the table can
2540
- // no longer silently drift from the schema (this replaces the #290 regex guard,
2541
- // which had to string-scrape these keys precisely because this used to be a
2542
- // string). The drift is also covered by a real-import unit test.
2628
+ // it, at compile time, to the shared `PAGE_NODE_TYPES` registry (`../core/page-nodes.ts`
2629
+ // — the canonical @nanobpm/nano-app-schema set, extended with the node types this
2630
+ // runtime ships ahead of the schema, e.g. `appView` #416). Adding a page-node type
2631
+ // without a renderer here (or vice-versa) is now a type error, so the table can no
2632
+ // longer silently drift from the registry (this replaces the #290 regex guard, which
2633
+ // had to string-scrape these keys precisely because this used to be a string). The
2634
+ // drift is also covered by a real-import unit test.
2543
2635
  /** @satisfies {Record<PageNodeType, Renderer>} */
2544
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
2636
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton, appView: renderAppView };
2545
2637
 
2546
2638
  // Durable per-node UI state. localStorage is keyed by the home page id + node
2547
2639
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -2706,4 +2798,4 @@ function boot() {
2706
2798
  // renderer functions / RENDERERS registry can be imported directly.
2707
2799
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2708
2800
 
2709
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
2801
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
@@ -6,8 +6,8 @@
6
6
  // a real import, and adds the first true renderer-level unit coverage.
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
10
- import { RENDERERS, renderText, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
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";
11
11
 
12
12
  // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
13
  // Just enough of the Element/Document surface that el()/renderText touch:
@@ -77,6 +77,16 @@ function installFakeDom(): () => void {
77
77
  };
78
78
  }
79
79
 
80
+ // A renderer statically returns HTMLElement but at runtime produces a FakeElement
81
+ // (the installed fake `document.createElement`). Narrow to FakeElement so a test can
82
+ // walk `.children` — without a banned `as` cast — by asserting the runtime shape.
83
+ function asFake(node: unknown): FakeElement {
84
+ if (!(node instanceof FakeElement)) {
85
+ throw new Error("expected a FakeElement (is the fake DOM installed?)");
86
+ }
87
+ return node;
88
+ }
89
+
80
90
  test("#291: RENDERERS is a real importable registry keyed exactly by PAGE_NODE_TYPES", () => {
81
91
  // The drift lock is enforced at compile time by `RENDERERS satisfies
82
92
  // Record<PageNodeType, Renderer>` in the source; this asserts the same at
@@ -117,6 +127,113 @@ test("#291: renderText degrades to empty text (never throws) on a missing text p
117
127
  assert.equal(node.textContent, "");
118
128
  });
119
129
 
130
+ test("#416: appView is a rendered node type (registry + bridged PAGE_NODE_TYPES)", () => {
131
+ // The whole point of #416: an `appView` page node must have a real renderer, not
132
+ // fall through to a blank <div>. It is in the RENDERERS registry and — since it
133
+ // ships here ahead of the canonical schema — in the locally-bridged PAGE_NODE_TYPES.
134
+ assert.ok(PAGE_NODE_TYPES.includes("appView"), "appView must be in the bridged PAGE_NODE_TYPES");
135
+ assert.equal(typeof RENDERERS.appView, "function", "appView must have a renderer");
136
+ });
137
+
138
+ test("#416: renderAppView mounts a base-relative iframe honoring embed/title/fill", (t) => {
139
+ t.after(installFakeDom());
140
+ const section = asFake(
141
+ renderAppView({
142
+ type: "appView",
143
+ props: { title: "Agent networks", embed: "./embed.html", fill: true },
144
+ }),
145
+ );
146
+ assert.equal(section.tagName, "SECTION");
147
+ // fill → the tall flex container variant so the embed gets viewport height.
148
+ assert.equal(section.className, "pc-appview pc-appview-fill");
149
+
150
+ const frame = section.children.find(
151
+ (c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
152
+ );
153
+ assert.ok(frame, "an <iframe> must be mounted");
154
+ // The iframe src is the node's embed, left document-relative so it resolves under
155
+ // both the app root and the console App-View proxy prefix.
156
+ assert.equal(frame.getAttribute("src"), "./embed.html");
157
+ assert.equal(frame.getAttribute("title"), "Agent networks");
158
+ assert.equal(frame.className, "pc-appview-frame");
159
+
160
+ // The title also renders as a visible label above the frame.
161
+ const label = section.children.find(
162
+ (c): c is FakeElement => c instanceof FakeElement && c.className === "pc-appview-title",
163
+ );
164
+ assert.ok(label, "the title renders as a label");
165
+ assert.equal(label.textContent, "Agent networks");
166
+ });
167
+
168
+ test("#416: renderAppView rebases a root-absolute embed onto the mount root", (t) => {
169
+ t.after(installFakeDom());
170
+ // A root-absolute "/embed.html" would escape the console proxy prefix and hit the
171
+ // console origin; the renderer strips the single leading slash to rebase it.
172
+ const section = asFake(renderAppView({ type: "appView", props: { embed: "/embed.html" } }));
173
+ const frame = section.children.find(
174
+ (c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
175
+ );
176
+ assert.ok(frame);
177
+ assert.equal(frame.getAttribute("src"), "embed.html");
178
+ });
179
+
180
+ test("#416: renderAppView is non-fill by default and never throws on missing props", (t) => {
181
+ t.after(installFakeDom());
182
+ const section = asFake(renderAppView({ type: "appView", props: {} }));
183
+ assert.equal(section.tagName, "SECTION");
184
+ assert.equal(section.className, "pc-appview");
185
+ const frame = section.children.find(
186
+ (c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
187
+ );
188
+ assert.ok(frame, "an <iframe> is still mounted with no embed");
189
+ // No usable embed → src is OMITTED (about:blank), not src="" (which resolves to
190
+ // the current document URL in browsers and risks a recursive self-embed).
191
+ assert.equal(frame.getAttribute("src"), null);
192
+ // No title → no label element rendered.
193
+ const label = section.children.find(
194
+ (c): c is FakeElement => c instanceof FakeElement && c.className === "pc-appview-title",
195
+ );
196
+ assert.equal(label, undefined);
197
+ });
198
+
199
+ test("#416: renderAppView neutralizes a dangerous-scheme embed (no executable iframe src)", (t) => {
200
+ t.after(installFakeDom());
201
+ // A hostile/malformed page doc must not be able to smuggle an executable iframe
202
+ // src via a javascript:/data:/vbscript: embed, nor bypass the check with embedded
203
+ // whitespace/tabs (browsers strip those before parsing the URL).
204
+ for (const embed of [
205
+ "javascript:alert(1)",
206
+ "data:text/html,<script>alert(1)</script>",
207
+ "vbscript:msgbox(1)",
208
+ " javascript:alert(1) ",
209
+ "java\tscript:alert(1)",
210
+ "JAVASCRIPT:alert(1)",
211
+ ]) {
212
+ const section = asFake(renderAppView({ type: "appView", props: { embed } }));
213
+ const frame = section.children.find(
214
+ (c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
215
+ );
216
+ assert.ok(frame, `an <iframe> is still mounted for ${JSON.stringify(embed)}`);
217
+ assert.equal(
218
+ frame.getAttribute("src"),
219
+ null,
220
+ `dangerous embed ${JSON.stringify(embed)} must not become an iframe src`,
221
+ );
222
+ }
223
+ });
224
+
225
+ test("#416: renderAppView keeps a legit http(s) embed and trims surrounding whitespace", (t) => {
226
+ t.after(installFakeDom());
227
+ const section = asFake(
228
+ renderAppView({ type: "appView", props: { embed: " https://example.test/embed.html " } }),
229
+ );
230
+ const frame = section.children.find(
231
+ (c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
232
+ );
233
+ assert.ok(frame);
234
+ assert.equal(frame.getAttribute("src"), "https://example.test/embed.html");
235
+ });
236
+
120
237
  test('#327: fmtCellValue "datetime" renders an ISO timestamp in the viewer\'s local time as "h:mmam Mon D"', () => {
121
238
  const iso = "2026-08-18T23:42:47.788Z";
122
239
  const out = fmtCellValue("datetime", iso);
@@ -1690,3 +1690,136 @@ test("the syntax guard is red/green: an injected syntax error fails `node --chec
1690
1690
  assert.equal(result.ok, false, "an injected syntax error must fail `node --check`");
1691
1691
  assert.match(result.stderr, /SyntaxError/);
1692
1692
  });
1693
+
1694
+ // ── appView sidecar + dist static-file serving (#420) ──────────────────────
1695
+ // The renderer (#419) mounts the cockpit iframe at `./embed.html`; in a DEPLOYED app
1696
+ // urban must serve that sidecar (and the `dist/` bundle its import-map references) or
1697
+ // the iframe src 404s and the cockpit stays blank (#416's user-facing symptom). These
1698
+ // tests pin the static-serving surface #420 adds.
1699
+
1700
+ /** Build the pages routes over an in-memory asset filesystem (app-root-relative path → body). */
1701
+ function buildWithAssets(files: Record<string, string>) {
1702
+ const { engine } = fakeEngine();
1703
+ const routes = createPagesRoutes(
1704
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
1705
+ {
1706
+ db: fakeDb(),
1707
+ engine,
1708
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
1709
+ readAsset: async (path: string) => {
1710
+ if (path in files) return files[path];
1711
+ throw new Error(`no such file: ${path}`);
1712
+ },
1713
+ listPages: async () => ["home"],
1714
+ },
1715
+ );
1716
+ return makeRouter(routes);
1717
+ }
1718
+
1719
+ test("GET /embed.html serves the pages-dir sidecar as HTML", async () => {
1720
+ const router = buildWithAssets({ "pages/embed.html": "<!doctype html><title>embed</title>" });
1721
+ const res = await router(req("GET", "/embed.html"));
1722
+ assert.equal(res.status, 200);
1723
+ assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
1724
+ assert.match(res.body ?? "", /<title>embed<\/title>/);
1725
+ });
1726
+
1727
+ test("all four appView sidecars are served from the pages dir with correct content types", async () => {
1728
+ const router = buildWithAssets({
1729
+ "pages/embed.html": "<html>embed</html>",
1730
+ "pages/standalone.html": "<html>standalone</html>",
1731
+ "pages/mount.js": "export const mountCockpit = () => {};",
1732
+ "pages/cockpit.css": "body{margin:0}",
1733
+ });
1734
+ const cases: [string, RegExp][] = [
1735
+ ["/embed.html", /text\/html/],
1736
+ ["/standalone.html", /text\/html/],
1737
+ ["/mount.js", /text\/javascript/],
1738
+ ["/cockpit.css", /text\/css/],
1739
+ ];
1740
+ for (const [path, ct] of cases) {
1741
+ const res = await router(req("GET", path));
1742
+ assert.equal(res.status, 200, `${path} should 200`);
1743
+ assert.match(res.headers?.["content-type"] ?? "", ct, `${path} content-type`);
1744
+ }
1745
+ });
1746
+
1747
+ test("a missing sidecar 404s instead of throwing", async () => {
1748
+ const router = buildWithAssets({}); // no files
1749
+ const res = await router(req("GET", "/embed.html"));
1750
+ assert.equal(res.status, 404);
1751
+ });
1752
+
1753
+ test("GET /dist/<path> serves the import-map bundle the embed references", async () => {
1754
+ const router = buildWithAssets({ "dist/cockpit/index.js": "export function bootCockpit(){}" });
1755
+ const res = await router(req("GET", "/dist/cockpit/index.js"));
1756
+ assert.equal(res.status, 200);
1757
+ assert.match(res.headers?.["content-type"] ?? "", /text\/javascript/);
1758
+ assert.match(res.body ?? "", /bootCockpit/);
1759
+ });
1760
+
1761
+ test("a source map under /dist/ is served as JSON", async () => {
1762
+ const router = buildWithAssets({ "dist/cockpit/index.js.map": "{\"version\":3}" });
1763
+ const res = await router(req("GET", "/dist/cockpit/index.js.map"));
1764
+ assert.equal(res.status, 200);
1765
+ assert.match(res.headers?.["content-type"] ?? "", /application\/json/);
1766
+ });
1767
+
1768
+ test("a /dist/ path traversal attempt is rejected (never reaches the filesystem)", async () => {
1769
+ // The reader would happily read `dist/../secret` if the route let it through; the
1770
+ // sub-path guard must reject any `..` / absolute / percent-escaped segment first.
1771
+ let readAttempts = 0;
1772
+ const { engine } = fakeEngine();
1773
+ const router = makeRouter(
1774
+ createPagesRoutes(
1775
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
1776
+ {
1777
+ db: fakeDb(),
1778
+ engine,
1779
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
1780
+ readAsset: async (path: string) => {
1781
+ readAttempts++;
1782
+ return `bytes of ${path}`;
1783
+ },
1784
+ listPages: async () => ["home"],
1785
+ },
1786
+ ),
1787
+ );
1788
+ for (const evil of ["/dist/../secret.json", "/dist/a/../../secret", "/dist/%2e%2e/secret", "/dist//etc"]) {
1789
+ const res = await router(req("GET", evil));
1790
+ assert.equal(res.status, 404, `${evil} must 404`);
1791
+ }
1792
+ assert.equal(readAttempts, 0, "no traversal path may reach the asset reader");
1793
+ });
1794
+
1795
+ test("the static asset routes do not shadow the app's own routes or /healthz", async () => {
1796
+ // The sidecars are served as EXACT routes (not a root catch-all), so `/`, `/app/*` and
1797
+ // the runtime's appended `/healthz` liveness route all still resolve. A catch-all would
1798
+ // silently swallow `/healthz` (breaking liveness probes) — this pins that it does not.
1799
+ const routes = createPagesRoutes(
1800
+ { pagesDir: "pages", homePage: "home", sourceName: "app" },
1801
+ {
1802
+ db: fakeDb(),
1803
+ engine: fakeEngine().engine,
1804
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
1805
+ readAsset: async () => "<html></html>",
1806
+ listPages: async () => ["home"],
1807
+ },
1808
+ );
1809
+ // No pages route matches /healthz, so the runtime's own healthz route (appended after
1810
+ // the pages surface) would still win. Assert none of the pages routes claim it.
1811
+ const router = makeRouter([
1812
+ ...routes,
1813
+ {
1814
+ method: "GET",
1815
+ path: "/healthz",
1816
+ handler: () => ({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }),
1817
+ },
1818
+ ]);
1819
+ const health = await router(req("GET", "/healthz"));
1820
+ assert.equal(health.status, 200);
1821
+ assert.deepEqual(JSON.parse(health.body ?? "{}"), { ok: true });
1822
+ // The home shell still renders (not shadowed by a static route).
1823
+ const home = await router(req("GET", "/"));
1824
+ assert.match(home.body ?? "", /data-home="home"/);
1825
+ });
@@ -11,6 +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)
15
+ // GET /dist/<path> → the app dist/ bundle the sidecars reference
14
16
  // POST /app/actions/start/<process> → engine.createInstance
15
17
  // POST /app/actions/cancel → engine.cancelInstance
16
18
  // POST /app/actions/message → engine.publishMessage
@@ -59,6 +61,16 @@ export interface PagesDeps {
59
61
  engine: EngineClient;
60
62
  /** Read a page file; injectable for tests. */
61
63
  readPage(path: string): Promise<string>;
64
+ /**
65
+ * Read a static asset file at an app-root-relative path (the `appView` sidecar
66
+ * documents under `pagesDir` — `embed.html`/`standalone.html`/`mount.js`/`cockpit.css`
67
+ * — and the app `dist/` bundle their import-maps reference). A thin adapter over the
68
+ * host's `readTextFile` seam, distinct from `readPage` (which is page.json-scoped) so
69
+ * the intent reads clearly at the call site. Optional; falls back to `readPage` when
70
+ * absent (both delegate to the same host seam), so existing tests need no change.
71
+ * Injectable for tests. Rejects (→ 404) when the file is missing.
72
+ */
73
+ readAsset?(path: string): Promise<string>;
62
74
  /**
63
75
  * List the available page ids (the `*.page.json` basenames under `pagesDir`),
64
76
  * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
@@ -88,6 +100,83 @@ function javascript(
88
100
  };
89
101
  }
90
102
 
103
+ // The `appView` sidecar documents an app ships alongside its `*.page.json` under
104
+ // `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
105
+ // src is the node's base-relative `embed` (`./embed.html`), which — resolved against
106
+ // the shell served at the app mount root — the browser fetches at `/embed.html`; that
107
+ // document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
108
+ // `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
109
+ // 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
+ // import-map references is served separately under the `/dist/` prefix below.
112
+ const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"] as const;
113
+
114
+ // The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
115
+ // resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
116
+ // resolves to `/dist` regardless of `pagesDir`, so the URL→disk mapping is fixed.
117
+ const DIST_DIR = "dist";
118
+
119
+ /** Content type for a served static web asset, by extension. The sidecars + the ESM/CSS/JSON
120
+ * the import-maps reference are all UTF-8 text (the only channel `readTextFile` offers); an
121
+ * unknown extension falls back to `application/octet-stream` rather than mislabelling. This is
122
+ * a distinct concern from deploy's `contentTypeFor` (engine resource types: bpmn/dmn/form/…),
123
+ * so the two maps do not share a source. */
124
+ function assetContentType(path: string): string {
125
+ const dot = path.lastIndexOf(".");
126
+ const ext = dot >= 0 ? path.slice(dot + 1).toLowerCase() : "";
127
+ switch (ext) {
128
+ case "html":
129
+ return "text/html; charset=utf-8";
130
+ case "js":
131
+ case "mjs":
132
+ return "text/javascript; charset=utf-8";
133
+ case "css":
134
+ return "text/css; charset=utf-8";
135
+ case "json":
136
+ case "map": // source maps are JSON
137
+ return "application/json; charset=utf-8";
138
+ case "svg":
139
+ return "image/svg+xml; charset=utf-8";
140
+ case "txt":
141
+ return "text/plain; charset=utf-8";
142
+ default:
143
+ return "application/octet-stream";
144
+ }
145
+ }
146
+
147
+ // A URL sub-path (the `<rest>` of `/dist/<rest>`) is safe to map onto the filesystem
148
+ // only when it is a plain, forward, relative descent: no `..`/`.` segment (path
149
+ // traversal out of `dist/`), no empty segment (a leading/doubled slash), no absolute
150
+ // path, and no percent-escape or backslash (the request `path` is `URL.pathname`, which
151
+ // is NOT percent-decoded — so a `%2e%2e`/`%2f` smuggled traversal never reaches the FS;
152
+ // we reject any `%` outright rather than decode-then-recheck). Returns the normalised
153
+ // relative path, or null to 404. Mirrors the `[A-Za-z0-9_-]` guards the page/data routes
154
+ // already apply, widened to the `/` `.` a nested asset path needs.
155
+ function safeAssetSubpath(rest: string): string | null {
156
+ if (rest === "" || rest.includes("%") || rest.includes("\\") || rest.includes("\0")) return null;
157
+ if (rest.startsWith("/")) return null;
158
+ const segs = rest.split("/");
159
+ for (const s of segs) {
160
+ if (s === "" || s === "." || s === "..") return null;
161
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return null;
162
+ }
163
+ return segs.join("/");
164
+ }
165
+
166
+ /** Serve a static asset file at an app-root-relative path: 200 with its content type, or 404 when
167
+ * the file is missing/unreadable. Used by both the root sidecar routes and the `/dist/` tree. */
168
+ async function serveAsset(
169
+ read: (path: string) => Promise<string>,
170
+ filePath: string,
171
+ ): Promise<{ status: number; headers: Record<string, string>; body: string }> {
172
+ try {
173
+ const body = await read(filePath);
174
+ return { status: 200, headers: { "content-type": assetContentType(filePath) }, body };
175
+ } catch {
176
+ return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
177
+ }
178
+ }
179
+
91
180
  /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
92
181
  * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
93
182
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -112,6 +201,10 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
112
201
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
113
202
  const sourceName = opts.sourceName ?? "app";
114
203
  const { db, engine, readPage, listPages, cancel } = deps;
204
+ // Static assets (the `appView` sidecars + the `dist/` bundle) read through the host seam.
205
+ // Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
206
+ // `host.readTextFile`, so there is one source of file bytes, not two.
207
+ const readAsset = deps.readAsset ?? readPage;
115
208
 
116
209
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
117
210
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -175,7 +268,41 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
175
268
  handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
176
269
  });
177
270
 
178
- // ── GET /app/pages (index of available pages) ───────────────────────────
271
+ // ── appView sidecar assets (#420) ───────────────────────────────────────
272
+ // The `appView` renderer (#419) mounts an <iframe> at the node's base-relative `embed`
273
+ // (`./embed.html`), which — against the shell served at the mount root — the browser
274
+ // fetches at `/embed.html`; that document then pulls `./cockpit.css`, `./mount.js`, and
275
+ // its import-map's `../dist/…`. A DEPLOYED app served none of these, so the iframe src
276
+ // 404'd and the agent cockpit loaded nothing (#416 was necessary but not sufficient
277
+ // without this static-serving surface). Serve each sidecar at its root URL, mapped 1:1
278
+ // onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
279
+ // `/healthz` liveness route the runtime appends after the pages surface).
280
+ for (const name of SIDECAR_ASSETS) {
281
+ routes.push({
282
+ method: "GET",
283
+ path: `/${name}`,
284
+ source: "surface:pages",
285
+ handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
286
+ });
287
+ }
288
+ // The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
289
+ // `/dist/cockpit/index.js`). A prefix route over an app-root `dist/` directory; the
290
+ // sub-path is strictly validated (no traversal/escape) before it reaches the FS.
291
+ routes.push({
292
+ method: "GET",
293
+ path: `/${DIST_DIR}/`,
294
+ prefix: true,
295
+ source: "surface:pages",
296
+ handler: (req) => {
297
+ const rest = req.path.slice(`/${DIST_DIR}/`.length);
298
+ const safe = safeAssetSubpath(rest);
299
+ if (safe === null) {
300
+ return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
301
+ }
302
+ return serveAsset(readAsset, `${DIST_DIR}/${safe}`);
303
+ },
304
+ });
305
+
179
306
  // Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
180
307
  // page here, and it lets any client enumerate the app's screens. Each entry
181
308
  // carries the page's own `title` (best-effort — an unreadable/!JSON page still
@@ -449,6 +576,7 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
449
576
  engine: app.engine,
450
577
  cancel: (key) => cancelInstanceReconciling(app, bindings, key),
451
578
  readPage: (p) => ctx.host.readTextFile(p),
579
+ readAsset: (p) => ctx.host.readTextFile(p),
452
580
  listPages: async () => {
453
581
  const dir = opts.pagesDir ?? "pages";
454
582
  const names = await ctx.host.listDir(dir).catch(() => []);
@@ -686,6 +814,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
686
814
  .pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
687
815
  .pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
688
816
  .pc-prose-body a { color:var(--nano-accent-strong); }
817
+ /* Embedded App View (renderAppView, #416 / ADR 0057). An <iframe> hosting a
818
+ self-contained view document (e.g. the agent cockpit's embed.html) served
819
+ alongside the page. A plain node sizes to a comfortable default; a "fill" node
820
+ grows to a tall flex column so the embed (a live dashboard/terminal) gets the
821
+ viewport height it needs instead of a squat fixed box. */
822
+ .pc-appview { margin:1rem 0; }
823
+ .pc-appview-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
824
+ .pc-appview-frame { display:block; width:100%; min-height:24rem; border:1px solid var(--nano-edge); border-radius:.6rem; background:var(--nano-inset); }
825
+ .pc-appview-fill { display:flex; flex-direction:column; min-height:min(80vh,48rem); }
826
+ .pc-appview-fill .pc-appview-frame { flex:1 1 auto; min-height:0; }
689
827
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
690
828
  .pc-buttonrow { margin:1rem 0; }
691
829
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }