@nanobpm/urban 0.73.0 → 0.74.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.
@@ -30,6 +30,16 @@ export interface PagesDeps {
30
30
  engine: EngineClient;
31
31
  /** Read a page file; injectable for tests. */
32
32
  readPage(path: string): Promise<string>;
33
+ /**
34
+ * Read a static asset file at an app-root-relative path (the `appView` sidecar
35
+ * documents under `pagesDir` — `embed.html`/`standalone.html`/`mount.js`/`cockpit.css`
36
+ * — and the app `dist/` bundle their import-maps reference). A thin adapter over the
37
+ * host's `readTextFile` seam, distinct from `readPage` (which is page.json-scoped) so
38
+ * the intent reads clearly at the call site. Optional; falls back to `readPage` when
39
+ * absent (both delegate to the same host seam), so existing tests need no change.
40
+ * Injectable for tests. Rejects (→ 404) when the file is missing.
41
+ */
42
+ readAsset?(path: string): Promise<string>;
33
43
  /**
34
44
  * List the available page ids (the `*.page.json` basenames under `pagesDir`),
35
45
  * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
@@ -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
@@ -36,6 +38,80 @@ function javascript(body, status = 200, extraHeaders) {
36
38
  body,
37
39
  };
38
40
  }
41
+ // The `appView` sidecar documents an app ships alongside its `*.page.json` under
42
+ // `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
43
+ // src is the node's base-relative `embed` (`./embed.html`), which — resolved against
44
+ // the shell served at the app mount root — the browser fetches at `/embed.html`; that
45
+ // document then pulls `./cockpit.css`, `./mount.js`, and (via its import-map)
46
+ // `../dist/…`. A DEPLOYED app served none of these, so the src 404'd and the cockpit
47
+ // iframe loaded nothing (#416's user-facing "nothing shows in the Cockpit"). These are
48
+ // the root-served sidecar files, mapped 1:1 onto `pagesDir/<name>`. The `dist/` tree the
49
+ // import-map references is served separately under the `/dist/` prefix below.
50
+ const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"];
51
+ // The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
52
+ // resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
53
+ // resolves to `/dist` regardless of `pagesDir`, so the URL→disk mapping is fixed.
54
+ const DIST_DIR = "dist";
55
+ /** Content type for a served static web asset, by extension. The sidecars + the ESM/CSS/JSON
56
+ * the import-maps reference are all UTF-8 text (the only channel `readTextFile` offers); an
57
+ * unknown extension falls back to `application/octet-stream` rather than mislabelling. This is
58
+ * a distinct concern from deploy's `contentTypeFor` (engine resource types: bpmn/dmn/form/…),
59
+ * so the two maps do not share a source. */
60
+ function assetContentType(path) {
61
+ const dot = path.lastIndexOf(".");
62
+ const ext = dot >= 0 ? path.slice(dot + 1).toLowerCase() : "";
63
+ switch (ext) {
64
+ case "html":
65
+ return "text/html; charset=utf-8";
66
+ case "js":
67
+ case "mjs":
68
+ return "text/javascript; charset=utf-8";
69
+ case "css":
70
+ return "text/css; charset=utf-8";
71
+ case "json":
72
+ case "map": // source maps are JSON
73
+ return "application/json; charset=utf-8";
74
+ case "svg":
75
+ return "image/svg+xml; charset=utf-8";
76
+ case "txt":
77
+ return "text/plain; charset=utf-8";
78
+ default:
79
+ return "application/octet-stream";
80
+ }
81
+ }
82
+ // A URL sub-path (the `<rest>` of `/dist/<rest>`) is safe to map onto the filesystem
83
+ // only when it is a plain, forward, relative descent: no `..`/`.` segment (path
84
+ // traversal out of `dist/`), no empty segment (a leading/doubled slash), no absolute
85
+ // path, and no percent-escape or backslash (the request `path` is `URL.pathname`, which
86
+ // is NOT percent-decoded — so a `%2e%2e`/`%2f` smuggled traversal never reaches the FS;
87
+ // we reject any `%` outright rather than decode-then-recheck). Returns the normalised
88
+ // relative path, or null to 404. Mirrors the `[A-Za-z0-9_-]` guards the page/data routes
89
+ // already apply, widened to the `/` `.` a nested asset path needs.
90
+ function safeAssetSubpath(rest) {
91
+ if (rest === "" || rest.includes("%") || rest.includes("\\") || rest.includes("\0"))
92
+ return null;
93
+ if (rest.startsWith("/"))
94
+ return null;
95
+ const segs = rest.split("/");
96
+ for (const s of segs) {
97
+ if (s === "" || s === "." || s === "..")
98
+ return null;
99
+ if (!/^[A-Za-z0-9._-]+$/.test(s))
100
+ return null;
101
+ }
102
+ return segs.join("/");
103
+ }
104
+ /** Serve a static asset file at an app-root-relative path: 200 with its content type, or 404 when
105
+ * the file is missing/unreadable. Used by both the root sidecar routes and the `/dist/` tree. */
106
+ async function serveAsset(read, filePath) {
107
+ try {
108
+ const body = await read(filePath);
109
+ return { status: 200, headers: { "content-type": assetContentType(filePath) }, body };
110
+ }
111
+ catch {
112
+ return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
113
+ }
114
+ }
39
115
  /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
40
116
  * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
41
117
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -59,6 +135,10 @@ export function createPagesRoutes(opts, deps) {
59
135
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
60
136
  const sourceName = opts.sourceName ?? "app";
61
137
  const { db, engine, readPage, listPages, cancel } = deps;
138
+ // Static assets (the `appView` sidecars + the `dist/` bundle) read through the host seam.
139
+ // Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
140
+ // `host.readTextFile`, so there is one source of file bytes, not two.
141
+ const readAsset = deps.readAsset ?? readPage;
62
142
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
63
143
  // (before serving), so the schema is stable for the process lifetime, and the renderer
64
144
  // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
@@ -114,7 +194,40 @@ export function createPagesRoutes(opts, deps) {
114
194
  source: "surface:pages",
115
195
  handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
116
196
  });
117
- // ── GET /app/pages (index of available pages) ───────────────────────────
197
+ // ── appView sidecar assets (#420) ───────────────────────────────────────
198
+ // The `appView` renderer (#419) mounts an <iframe> at the node's base-relative `embed`
199
+ // (`./embed.html`), which — against the shell served at the mount root — the browser
200
+ // fetches at `/embed.html`; that document then pulls `./cockpit.css`, `./mount.js`, and
201
+ // its import-map's `../dist/…`. A DEPLOYED app served none of these, so the iframe src
202
+ // 404'd and the agent cockpit loaded nothing (#416 was necessary but not sufficient
203
+ // without this static-serving surface). Serve each sidecar at its root URL, mapped 1:1
204
+ // onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
205
+ // `/healthz` liveness route the runtime appends after the pages surface).
206
+ for (const name of SIDECAR_ASSETS) {
207
+ routes.push({
208
+ method: "GET",
209
+ path: `/${name}`,
210
+ source: "surface:pages",
211
+ handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
212
+ });
213
+ }
214
+ // The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
215
+ // `/dist/cockpit/index.js`). A prefix route over an app-root `dist/` directory; the
216
+ // sub-path is strictly validated (no traversal/escape) before it reaches the FS.
217
+ routes.push({
218
+ method: "GET",
219
+ path: `/${DIST_DIR}/`,
220
+ prefix: true,
221
+ source: "surface:pages",
222
+ handler: (req) => {
223
+ const rest = req.path.slice(`/${DIST_DIR}/`.length);
224
+ const safe = safeAssetSubpath(rest);
225
+ if (safe === null) {
226
+ return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
227
+ }
228
+ return serveAsset(readAsset, `${DIST_DIR}/${safe}`);
229
+ },
230
+ });
118
231
  // Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
119
232
  // page here, and it lets any client enumerate the app's screens. Each entry
120
233
  // carries the page's own `title` (best-effort — an unreadable/!JSON page still
@@ -400,6 +513,7 @@ export function mountPages(ctx, app) {
400
513
  engine: app.engine,
401
514
  cancel: (key) => cancelInstanceReconciling(app, bindings, key),
402
515
  readPage: (p) => ctx.host.readTextFile(p),
516
+ readAsset: (p) => ctx.host.readTextFile(p),
403
517
  listPages: async () => {
404
518
  const dir = opts.pagesDir ?? "pages";
405
519
  const names = await ctx.host.listDir(dir).catch(() => []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.73.0",
3
+ "version": "0.74.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",
@@ -101,7 +101,7 @@
101
101
  "dependencies": {
102
102
  "@nanobpm/nano-app-schema": "^0.9.0",
103
103
  "@nanobpm/nano-sdk": "^1.2.5",
104
- "@nanobpm/workflow": "^0.13.1",
104
+ "@nanobpm/workflow": "^0.14.0",
105
105
  "bpmn-auto-layout": "^2.0.0-alpha.2",
106
106
  "create-urban-app": "^0.15.0",
107
107
  "yaml": "^2.9.0"
@@ -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(() => []);