@nanobpm/urban 0.48.0 → 0.49.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.
Files changed (73) hide show
  1. package/README.md +80 -21
  2. package/dist/runtime/adapters/deno.js +22 -4
  3. package/dist/runtime/adapters/node.js +34 -4
  4. package/dist/runtime/core/context.d.ts +0 -4
  5. package/dist/runtime/core/glob.d.ts +9 -0
  6. package/dist/runtime/core/glob.js +21 -0
  7. package/dist/runtime/core/host.d.ts +82 -8
  8. package/dist/runtime/core/host.js +13 -0
  9. package/dist/runtime/core/manifest.d.ts +89 -2
  10. package/dist/runtime/core/manifest.js +72 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +9 -3
  12. package/dist/runtime/core/modules/deploy.js +69 -37
  13. package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
  14. package/dist/runtime/core/modules/instance-tracking.js +20 -5
  15. package/dist/runtime/core/modules/pages.js +34 -2
  16. package/dist/runtime/core/modules/surfaces.js +200 -19
  17. package/dist/runtime/core/router.d.ts +7 -0
  18. package/dist/runtime/core/router.js +9 -0
  19. package/dist/runtime/core/runtime.d.ts +0 -5
  20. package/dist/runtime/core/runtime.js +27 -4
  21. package/dist/runtime/core/validate.js +65 -1
  22. package/dist/runtime/engine/nanosdk.d.ts +10 -6
  23. package/dist/runtime/engine/nanosdk.js +72 -2
  24. package/dist/runtime/index.d.ts +3 -5
  25. package/dist/runtime/index.js +3 -4
  26. package/dist/runtime/run.js +0 -1
  27. package/dist/toolkit/fsio.js +9 -0
  28. package/dist/toolkit/gen.d.ts +26 -1
  29. package/dist/toolkit/gen.js +54 -6
  30. package/dist/toolkit/models.d.ts +6 -4
  31. package/dist/toolkit/models.js +7 -4
  32. package/package.json +1 -1
  33. package/src/runtime/adapters/deno.ts +26 -6
  34. package/src/runtime/adapters/node.test.ts +67 -0
  35. package/src/runtime/adapters/node.ts +37 -4
  36. package/src/runtime/core/context.ts +0 -4
  37. package/src/runtime/core/glob.ts +24 -0
  38. package/src/runtime/core/host.ts +86 -4
  39. package/src/runtime/core/manifest.test.ts +61 -1
  40. package/src/runtime/core/manifest.ts +136 -2
  41. package/src/runtime/core/modules/actions.test.ts +1 -0
  42. package/src/runtime/core/modules/api.test.ts +1 -0
  43. package/src/runtime/core/modules/cancel.test.ts +1 -0
  44. package/src/runtime/core/modules/connectors.test.ts +1 -0
  45. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  46. package/src/runtime/core/modules/deploy.test.ts +97 -161
  47. package/src/runtime/core/modules/deploy.ts +69 -37
  48. package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
  49. package/src/runtime/core/modules/instance-tracking.ts +19 -6
  50. package/src/runtime/core/modules/pages.test.ts +43 -0
  51. package/src/runtime/core/modules/pages.ts +34 -2
  52. package/src/runtime/core/modules/surfaces.test.ts +214 -5
  53. package/src/runtime/core/modules/surfaces.ts +199 -19
  54. package/src/runtime/core/modules/triggers.test.ts +1 -0
  55. package/src/runtime/core/modules/workers.test.ts +1 -0
  56. package/src/runtime/core/router.test.ts +10 -1
  57. package/src/runtime/core/router.ts +10 -0
  58. package/src/runtime/core/runtime.test.ts +67 -1
  59. package/src/runtime/core/runtime.ts +28 -9
  60. package/src/runtime/core/validate.test.ts +145 -0
  61. package/src/runtime/core/validate.ts +64 -1
  62. package/src/runtime/engine/nanosdk.test.ts +130 -1
  63. package/src/runtime/engine/nanosdk.ts +80 -3
  64. package/src/runtime/index.ts +11 -4
  65. package/src/runtime/run.ts +0 -1
  66. package/src/toolkit/fsio.ts +8 -0
  67. package/src/toolkit/gen.test.ts +46 -10
  68. package/src/toolkit/gen.ts +59 -6
  69. package/src/toolkit/models.test.ts +5 -5
  70. package/src/toolkit/models.ts +8 -5
  71. package/dist/runtime/core/modules/templates.d.ts +0 -18
  72. package/dist/runtime/core/modules/templates.js +0 -133
  73. package/src/runtime/core/modules/templates.ts +0 -153
@@ -40,6 +40,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
40
40
  calls.messages.push(input);
41
41
  },
42
42
  async searchUserTasks() { return []; },
43
+ async getForm() { return null; },
43
44
  async completeUserTask() {},
44
45
  async searchProcessInstances() { return []; },
45
46
  async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
@@ -601,6 +602,48 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
601
602
  assert.match(js, /container\.append\(header, body\);\s*return container;/);
602
603
  });
603
604
 
605
+ test("renderer surfaces a live row-count badge on a dataGrid's collapsible header (showCount)", async () => {
606
+ // A dataGrid opting in via `showCount:true` renders a count pill that reflects the
607
+ // currently-fetched, filtered (active-tab) row set, updates on every refresh (the
608
+ // refreshMs poll and pc:refresh), and — because makeCollapsible re-homes it into the
609
+ // collapse header — stays visible while the section is collapsed. Omitting showCount
610
+ // is a no-op (backward-compatible).
611
+ const res = await dispatch("GET", "/app/runtime.js");
612
+ const js = res.body ?? "";
613
+ // Opt-in badge, created only when showCount is set; class-tagged for re-homing + CSS.
614
+ assert.match(js, /const countBadge = p\.showCount \? el\("span", \{ class: "pc-badge pc-count-badge"/);
615
+ // The badge's accessible name carries the count (kept in sync on refresh) rather than a
616
+ // bare aria-label that would override the number screen readers need to announce.
617
+ assert.match(js, /"aria-label": rowCountLabel\(0\)/);
618
+ assert.match(js, /function rowCountLabel\(n\) \{ return n \+ \(n === 1 \? " row" : " rows"\); \}/);
619
+ // The <h2> (title and/or badge) renders when either the title or the badge is present.
620
+ assert.match(js, /if \(p\.title \|\| countBadge\) \{/);
621
+ // The badge is appended to the <h2>; the " " spacer is inserted only when a title is
622
+ // present, so a badge-only header can't render (or store) a stray leading space.
623
+ assert.match(js, /if \(countBadge\) \{ if \(p\.title\) h2\.append\(" "\); h2\.append\(countBadge\); \}/);
624
+ // Every refresh rewrites the count from the fetched, filtered row set (poll + pc:refresh),
625
+ // keeping both the visible text and the accessible label in sync.
626
+ assert.match(js, /if \(countBadge\) \{ countBadge\.textContent = String\(rows\.length\); countBadge\.setAttribute\("aria-label", rowCountLabel\(rows\.length\)\); \}/);
627
+ // makeCollapsible lifts the badge out of the <h2> before deriving the title (so the
628
+ // count can't leak into the label) and re-homes it in the header so it stays visible
629
+ // while collapsed (only the body is hidden).
630
+ assert.match(js, /const countBadge = h2 && h2\.querySelector \? h2\.querySelector\("\.pc-count-badge"\) : null;/);
631
+ assert.match(js, /if \(countBadge\) countBadge\.remove\(\);/);
632
+ assert.match(js, /if \(countBadge\) header\.append\(countBadge\);/);
633
+ // The derived collapse-header title is trimmed so a badge-only <h2> (no title) can't
634
+ // leave a lone whitespace node as the label (or leak whitespace into the storage key).
635
+ assert.match(js, /const titleText = props\.title \|\| \(h2 \? h2\.textContent\.trim\(\) : ""\) \|\| "Section";/);
636
+ });
637
+
638
+ test("the shell styles the row-count pill as a neutral count distinct from status badges", async () => {
639
+ const res = await dispatch("GET", "/");
640
+ const html = res.body ?? "";
641
+ const rule = (html.match(/\.pc-count-badge\s*\{[^}]*\}/) ?? [""])[0];
642
+ assert.ok(rule, ".pc-count-badge rule must be present");
643
+ assert.match(rule, /background:var\(--nano-inset\)/);
644
+ assert.match(rule, /color:var\(--nano-text\)/);
645
+ });
646
+
604
647
  test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
605
648
  const ok = await dispatch("GET", "/app/pages/home");
606
649
  assert.equal(ok.status, 200);
@@ -564,6 +564,9 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
564
564
  .pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
565
565
  .pc-badge-ok { background:var(--nano-ok); }
566
566
  .pc-badge-info { background:var(--nano-info); }
567
+ /* Live row-count pill (dataGrid showCount): a subtle, neutral count distinct from
568
+ the coloured status badges, legible in the collapse header while collapsed. */
569
+ .pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
567
570
  :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
568
571
  @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
569
572
  .pc-child { margin:.6rem 0; }
@@ -1266,10 +1269,25 @@ function renderActionForm(node) {
1266
1269
  return card;
1267
1270
  }
1268
1271
 
1272
+ // Accessible name for the row-count badge. The visible text is just a number, so an
1273
+ // aria-label would override it with a bare "row count" (the count — the important part —
1274
+ // is dropped). Instead name it "N row(s)" and keep it in sync with the text on refresh.
1275
+ // Single source of truth for both the initial render and every refresh below.
1276
+ function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1277
+
1269
1278
  function renderDataGrid(node) {
1270
1279
  const p = node.props;
1271
1280
  const card = el("section", { class: "pc-card" });
1272
- if (p.title) card.append(el("h2", {}, p.title));
1281
+ // Opt-in live row-count badge ("showCount"). Rendered inside the <h2> so a plain
1282
+ // (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it
1283
+ // into the collapse header so it stays visible — and refresh-updated — while the
1284
+ // section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.
1285
+ const countBadge = p.showCount ? el("span", { class: "pc-badge pc-count-badge", "aria-label": rowCountLabel(0) }, "0") : null;
1286
+ if (p.title || countBadge) {
1287
+ const h2 = el("h2", {}, p.title || "");
1288
+ if (countBadge) { if (p.title) h2.append(" "); h2.append(countBadge); }
1289
+ card.append(h2);
1290
+ }
1273
1291
  const cols = p.columns || [];
1274
1292
  const tabs = p.tabs || [];
1275
1293
  const rowActions = p.rowActions || [];
@@ -1587,6 +1605,10 @@ function renderDataGrid(node) {
1587
1605
  const { rows } = paramScoped && PARAM === ""
1588
1606
  ? { rows: [] }
1589
1607
  : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1608
+ // Live row-count badge: reflect the currently-fetched, filtered (active-tab)
1609
+ // set — exactly what the body renders — on every refresh (poll + pc:refresh),
1610
+ // whether the section is expanded or collapsed.
1611
+ if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute("aria-label", rowCountLabel(rows.length)); }
1590
1612
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1591
1613
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1592
1614
  if (p.rowKey) {
@@ -1755,7 +1777,16 @@ function makeCollapsible(node, card) {
1755
1777
  const isCard = card.tagName === "SECTION";
1756
1778
  const container = isCard ? card : el("section", { class: "pc-card" });
1757
1779
  const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
1758
- const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
1780
+ // A dataGrid with "showCount" renders a live count badge inside its <h2>. Lift it
1781
+ // out before deriving the title (so the count doesn't leak into the label text),
1782
+ // then re-home it in the header alongside the title so it stays visible — and
1783
+ // refresh-updated by the grid's own refresh() — while the section is collapsed.
1784
+ const countBadge = h2 && h2.querySelector ? h2.querySelector(".pc-count-badge") : null;
1785
+ if (countBadge) countBadge.remove();
1786
+ // Trim the derived label: after the badge is lifted out, the leftover " " whitespace
1787
+ // node from h2.append(" ", countBadge) would otherwise make a title-less section's
1788
+ // header a lone space (and leak whitespace into the storage key). Fall back to "Section".
1789
+ const titleText = props.title || (h2 ? h2.textContent.trim() : "") || "Section";
1759
1790
  if (h2) h2.remove();
1760
1791
  const body = el("div", { class: "pc-card-body" });
1761
1792
  if (isCard) {
@@ -1770,6 +1801,7 @@ function makeCollapsible(node, card) {
1770
1801
  chevron,
1771
1802
  el("span", { class: "pc-collapse-title" }, titleText),
1772
1803
  );
1804
+ if (countBadge) header.append(countBadge);
1773
1805
  const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
1774
1806
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
1775
1807
  function apply() {
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
29
29
  cancelInstance: async () => {},
30
30
  publishMessage: async () => {},
31
31
  searchUserTasks: async () => [],
32
+ getForm: async () => null,
32
33
  completeUserTask: async () => {},
33
34
  searchProcessInstances: async () => [],
34
35
  registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
@@ -43,6 +44,7 @@ const fakeEngine: EngineClient = {
43
44
  cancelInstance: async () => {},
44
45
  publishMessage: async () => {},
45
46
  searchUserTasks: async () => [],
47
+ getForm: async () => null,
46
48
  completeUserTask: async () => {},
47
49
  searchProcessInstances: async () => [],
48
50
  registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
@@ -76,13 +78,220 @@ test("chat agent is HTML-escaped in the mount page", async () => {
76
78
  assert.ok(body.includes("&lt;img src=x onerror=alert(1)&gt;"), "agent is escaped");
77
79
  });
78
80
 
79
- test("task-inbox path is injected as a quoted JS literal (no script breakout)", async () => {
81
+ test("task-inbox client derives its API base from location (reverse-proxy safe, no path embedding)", async () => {
82
+ // The client must NOT embed the manifest-supplied route base as an absolute
83
+ // literal: a hardcoded "/tasks" escapes the Nano console's path-prefixed
84
+ // reverse proxy (/console/app-view/<name>/tasks) — every fetch to
85
+ // "/tasks/api/…" 404s upstream. Deriving the base from location.pathname keeps
86
+ // the API calls document-relative (correct at the origin root AND under the
87
+ // proxy) and, as a bonus, removes the manifest→<script> injection surface
88
+ // entirely: nothing manifest-supplied is embedded in the page at all.
80
89
  const evil = "/tasks'});alert(1);//x";
81
90
  const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: evil } }), fakeApp);
82
91
  const page = s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && !r.path.endsWith("/api/tasks"))!;
83
92
  const body = await render(page);
84
- // The path is embedded via JSON.stringify, so the raw breakout sequence must
85
- // not appear unescaped inside the <script>.
86
- assert.ok(!body.includes("'});alert(1);//'"), "no raw single-quoted breakout");
87
- assert.ok(body.includes(JSON.stringify(evil)), "path embedded as a JSON string literal");
93
+ // The manifest path (evil or otherwise) is never emitted into the page.
94
+ assert.ok(!body.includes("alert(1)"), "manifest path is not embedded — nothing to inject");
95
+ assert.ok(!body.includes(JSON.stringify(evil)), "manifest path is not embedded as a JS literal");
96
+ // The base is derived from where the page was actually served, so it inherits
97
+ // any reverse-proxy path prefix instead of a root-absolute mount path.
98
+ assert.ok(body.includes("location.pathname"), "client derives its API base from location.pathname");
99
+ assert.ok(!/const\s+BASE\s*=\s*["'/]/.test(body), "client does not embed an absolute route base");
88
100
  });
101
+
102
+ /** Build an app handle backed by a specific engine (the flow tests drive the routes). */
103
+ function appWith(engine: EngineClient): Parameters<typeof mountSurfaces>[1] {
104
+ return {
105
+ manifest: { schemaVersion: 1, id: "app", name: "App" },
106
+ data: new DataLayer(new Map(), undefined, {}),
107
+ engine,
108
+ env: () => undefined,
109
+ log: createLogger(() => {}),
110
+ };
111
+ }
112
+
113
+ /** Invoke a route handler with an optional query string and POST body. */
114
+ async function call(
115
+ route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> },
116
+ opts: { method?: string; query?: string; body?: string } = {},
117
+ ): Promise<HttpResponse> {
118
+ return route.handler({
119
+ method: opts.method ?? "GET",
120
+ path: "/",
121
+ query: new URLSearchParams(opts.query ?? ""),
122
+ headers: new Headers(),
123
+ text: async () => opts.body ?? "",
124
+ });
125
+ }
126
+
127
+ function inboxRoutes(engine: EngineClient) {
128
+ const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: "/tasks" } }), appWith(engine));
129
+ const at = (suffix: string, method = "GET") =>
130
+ s.routes.find(
131
+ (r) => r.source === "surface:taskInbox" && r.method === method && r.path.endsWith(suffix),
132
+ )!;
133
+ return {
134
+ page: s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && r.path === "/tasks")!,
135
+ tasks: at("/api/tasks"),
136
+ form: at("/api/form"),
137
+ complete: at("/api/complete", "POST"),
138
+ };
139
+ }
140
+
141
+ test("inbox page renders the client-side form fetch + renderer", async () => {
142
+ const { page } = inboxRoutes(fakeEngine);
143
+ const rendered = String((await call(page)).body);
144
+ assert.ok(rendered.includes("/api/form"), "page fetches the linked form");
145
+ assert.ok(rendered.includes("function renderForm"), "page has a form renderer");
146
+ assert.ok(rendered.includes("function buildField"), "page builds fields from the schema");
147
+ assert.ok(rendered.includes("/api/complete"), "page posts completion");
148
+ // Drift guard: the client script is a stringified <script> body, so it is invisible to
149
+ // the type checker. UserTaskSummary carries `formKey`/`externalFormReference` but no
150
+ // `formId`, so the client must never branch on a task-supplied `formId` (that phantom
151
+ // field would silently be `undefined`). Lock the drift closed.
152
+ assert.ok(!rendered.includes("formId"), "client script must not reference formId — UserTaskSummary carries none");
153
+ // Prototype-pollution guard: submit builds `variables` from engine-supplied component
154
+ // keys (a schema could key a field '__proto__'/'constructor'). It must use a null-
155
+ // prototype bag so an untrusted key lands as an own property, never mutating a prototype.
156
+ assert.ok(rendered.includes("Object.create(null)"), "client builds the variables bag with a null prototype");
157
+ assert.ok(!/const\s+variables\s*=\s*\{\}/.test(rendered), "client must not collect untrusted keys into a plain {} object");
158
+ });
159
+
160
+ test("/api/tasks surfaces the resolved form linkage", async () => {
161
+ let seenFilter: unknown;
162
+ const engine: EngineClient = {
163
+ ...fakeEngine,
164
+ searchUserTasks: async (filter) => {
165
+ seenFilter = filter;
166
+ return [
167
+ { userTaskKey: "1", elementId: "approve", formKey: "form-123" },
168
+ { userTaskKey: "2", elementId: "review" },
169
+ ];
170
+ },
171
+ };
172
+ const { tasks } = inboxRoutes(engine);
173
+ const res = await call(tasks, { query: "processInstanceKey=pi-9" });
174
+ assert.deepEqual(seenFilter, { state: "CREATED", processInstanceKey: "pi-9" });
175
+ const parsed = JSON.parse(String(res.body));
176
+ assert.equal(parsed[0].formKey, "form-123");
177
+ assert.equal(parsed[1].formKey, undefined);
178
+ });
179
+
180
+ test("/api/tasks constrains the search to open (CREATED) tasks", async () => {
181
+ let seenFilter: unknown;
182
+ const engine: EngineClient = {
183
+ ...fakeEngine,
184
+ searchUserTasks: async (filter) => {
185
+ seenFilter = filter;
186
+ return [];
187
+ },
188
+ };
189
+ const { tasks } = inboxRoutes(engine);
190
+ await call(tasks);
191
+ // No processInstanceKey supplied, but the inbox must still pin state=CREATED so that
192
+ // already-answered/canceled tasks never surface as answerable (nanobpm/nano-ide#248).
193
+ assert.deepEqual(seenFilter, { state: "CREATED" });
194
+ });
195
+
196
+ test("/api/form resolves a linked form's schema", async () => {
197
+ let seen: unknown;
198
+ const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name", label: "Name" }] };
199
+ const engine: EngineClient = {
200
+ ...fakeEngine,
201
+ getForm: async (input) => {
202
+ seen = input;
203
+ return { formKey: "form-123", schema };
204
+ },
205
+ };
206
+ const { form } = inboxRoutes(engine);
207
+ const res = await call(form, { query: "formKey=form-123" });
208
+ assert.deepEqual(seen, { formKey: "form-123", formId: undefined });
209
+ assert.equal(res.status, 200);
210
+ assert.deepEqual(JSON.parse(String(res.body)).schema, schema);
211
+ });
212
+
213
+ test("/api/form requires an identifier", async () => {
214
+ const { form } = inboxRoutes(fakeEngine);
215
+ const res = await call(form);
216
+ assert.equal(res.status, 400);
217
+ });
218
+
219
+ test("/api/form passes an empty formKey through so getForm can fall back to formId", async () => {
220
+ // A `?formKey=&formId=…` request must not be rejected at the boundary (only *both*
221
+ // identifiers absent is a 400) nor stripped here — resolving an empty key to its formId
222
+ // fallback is getForm's single responsibility (see nanosdk getForm empty/whitespace test).
223
+ let seen: unknown;
224
+ const engine: EngineClient = {
225
+ ...fakeEngine,
226
+ getForm: async (input) => {
227
+ seen = input;
228
+ return { formKey: "form-9", schema: { type: "default", components: [] } };
229
+ },
230
+ };
231
+ const { form } = inboxRoutes(engine);
232
+ const res = await call(form, { query: "formKey=&formId=form-9" });
233
+ assert.equal(res.status, 200);
234
+ assert.deepEqual(seen, { formKey: "", formId: "form-9" });
235
+ });
236
+
237
+ test("/api/form 400s a whitespace-only identifier instead of a spurious 204", async () => {
238
+ // A `?formKey= ` (or `formId= `) request provides no usable identifier. The route's
239
+ // presence gate must follow getForm's canonical rule (whitespace = absent) and 400,
240
+ // rather than letting the blank key slip past a raw truthiness check to getForm (→ null
241
+ // → 204). Guards the route↔getForm presence drift on the taskInbox form path — Red when
242
+ // the gate checks raw `!formKey && !formId`, since " " is truthy.
243
+ const { form } = inboxRoutes(fakeEngine);
244
+ assert.equal((await call(form, { query: "formKey=%20%20%20" })).status, 400);
245
+ assert.equal((await call(form, { query: "formId=%20%20" })).status, 400);
246
+ });
247
+
248
+ test("/api/form returns 204 for a task with no resolvable form (no-form fallback)", async () => {
249
+ const engine: EngineClient = { ...fakeEngine, getForm: async () => null };
250
+ const { form } = inboxRoutes(engine);
251
+ const res = await call(form, { query: "formKey=missing" });
252
+ assert.equal(res.status, 204);
253
+ // The body must be empty: a "null" payload (json(null, 204)) makes the client's
254
+ // fetch helper throw on parse and surface "Failed to load form" instead of the
255
+ // no-form fallback. Guard the wire shape here and the client short-circuit below.
256
+ assert.equal(res.body, "");
257
+ });
258
+
259
+ test("inbox client fetch helper short-circuits 204 so the no-form fallback renders", async () => {
260
+ const { page } = inboxRoutes(fakeEngine);
261
+ const rendered = String((await call(page)).body);
262
+ // The api() helper must not call r.json() on a 204 (empty body → throw). It has
263
+ // to resolve to null so openForm() falls through to renderNoForm(t).
264
+ assert.ok(rendered.includes("r.status===204"), "api() short-circuits a 204 response");
265
+ assert.ok(rendered.includes("function renderNoForm"), "page has a no-form renderer");
266
+ });
267
+
268
+ test("number field reader guards against a non-numeric (tampered) value", async () => {
269
+ // A number field submits `Number(raw)`; a non-numeric value (only reachable via tampering
270
+ // — the browser blanks invalid type=number input) yields NaN, which JSON.stringify
271
+ // serializes as null, silently changing the submission. The inline reader must guard it
272
+ // with Number.isFinite so a bad value is treated as absent rather than submitted as NaN.
273
+ const { page } = inboxRoutes(fakeEngine);
274
+ const rendered = String((await call(page)).body);
275
+ assert.ok(rendered.includes("Number.isFinite"), "number field guards NaN via Number.isFinite");
276
+ });
277
+
278
+ test("/api/complete completes the task with the submitted variables", async () => {
279
+ const calls: { key: string; variables?: Record<string, unknown> }[] = [];
280
+ const engine: EngineClient = {
281
+ ...fakeEngine,
282
+ completeUserTask: async (key, variables) => {
283
+ calls.push({ key, variables });
284
+ },
285
+ };
286
+ const { complete } = inboxRoutes(engine);
287
+ const res = await call(complete, { method: "POST", body: JSON.stringify({ userTaskKey: "7", variables: { name: "Ada", agree: true } }) });
288
+ assert.equal(res.status, 200);
289
+ assert.deepEqual(calls, [{ key: "7", variables: { name: "Ada", agree: true } }]);
290
+ });
291
+
292
+ test("/api/complete rejects a body with no userTaskKey", async () => {
293
+ const { complete } = inboxRoutes(fakeEngine);
294
+ const res = await call(complete, { method: "POST", body: JSON.stringify({ variables: {} }) });
295
+ assert.equal(res.status, 400);
296
+ });
297
+
@@ -1,10 +1,15 @@
1
1
  // surfaces — the hosted human surfaces (ADR 0026). Implements the `taskInbox` surface: a
2
- // small JSON+HTML task list backed by the engine's user-task search, that renders the
3
- // linked `.form` and posts completion back. The `chat` surface is stubbed as a mount point
4
- // (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
2
+ // small JSON+HTML task list backed by the engine's user-task search. Each open task links to
3
+ // its deployed `.form` via the engine-resolved `formKey`; the list fetches that form's form-js
4
+ // schema (`GET base/api/form`), renders its fields client-side with a minimal built-in
5
+ // renderer, and posts the entered field values as the completion `variables` (`POST
6
+ // base/api/complete`). A task with no linked form keeps the bare key-list + raw-complete
7
+ // behavior. The `chat` surface is stubbed as a mount point (LLM wiring is a separate concern).
8
+ // Surfaces contribute routes to the shared server.
5
9
 
6
10
  import type { AppApi, RuntimeContext } from "../context.ts";
7
- import { html, json, normalizeRoutePath, type Route } from "../router.ts";
11
+ import { presentFormIdentifier } from "../host.ts";
12
+ import { html, json, noContent, normalizeRoutePath, type Route } from "../router.ts";
8
13
  import { mountActions } from "./actions.ts";
9
14
  import { mountApi } from "./api.ts";
10
15
  import { mountPages } from "./pages.ts";
@@ -22,23 +27,169 @@ export interface SurfacesHandle {
22
27
  describe(): Record<string, unknown>;
23
28
  }
24
29
 
25
- function inboxPage(basePath: string): string {
30
+ function inboxPage(): string {
31
+ // Reverse-proxy safe: the client derives its API base from location.pathname
32
+ // (where THIS page was actually served) rather than an embedded absolute
33
+ // route base. A hardcoded "/tasks" would escape the Nano console's
34
+ // path-prefixed proxy (/console/app-view/<name>/tasks) — the API fetches must
35
+ // inherit that prefix. Deriving from location also removes the manifest→
36
+ // <script> injection surface entirely: nothing manifest-supplied is embedded.
37
+ // All task/form values are rendered via textContent / setAttribute — never
38
+ // innerHTML — so nothing the engine returns can inject markup.
26
39
  return `<!doctype html><meta charset="utf-8"><title>Task inbox</title>
27
40
  <style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:52rem}
28
- li{margin:.4rem 0}code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}</style>
41
+ ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
42
+ code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
43
+ button{font:inherit;padding:.2rem .6rem;cursor:pointer}
44
+ form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
45
+ form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
46
+ form .field input[type=checkbox],form .field input[type=radio]{width:auto}
47
+ #form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
48
+ .muted{color:#666}</style>
29
49
  <h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
50
+ <div id="form"></div>
30
51
  <script>
31
- fetch(${JSON.stringify(basePath)}+'/api/tasks').then(r=>r.json()).then(ts=>{
32
- const ul=document.getElementById('tasks');
33
- ul.replaceChildren();
34
- if(!ts.length){const li=document.createElement('li');li.textContent='No open tasks.';ul.appendChild(li);return;}
35
- for(const t of ts){const li=document.createElement('li');
36
- const code=document.createElement('code');
37
- code.textContent=t.elementId||t.userTaskKey;
38
- li.appendChild(code);
39
- li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
40
- ul.appendChild(li);}
41
- });
52
+ const BASE=location.pathname.replace(/\/+$/,'');
53
+ const tasksEl=document.getElementById('tasks');
54
+ const formEl=document.getElementById('form');
55
+
56
+ function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.text().then(t=>{throw new Error(t||r.status)});if(r.status===204)return null;return r.json();});}
57
+
58
+ function loadTasks(){
59
+ formEl.replaceChildren();
60
+ tasksEl.replaceChildren(el('li',{},'loading…'));
61
+ return api('/api/tasks').then(ts=>{
62
+ tasksEl.replaceChildren();
63
+ if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
64
+ for(const t of ts){
65
+ const li=document.createElement('li');
66
+ const code=el('code',{},t.elementId||t.userTaskKey);
67
+ li.appendChild(code);
68
+ li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
69
+ if(t.formKey){
70
+ li.appendChild(btn('Open',()=>openForm(t)));
71
+ }else{
72
+ li.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
73
+ }
74
+ tasksEl.appendChild(li);
75
+ }
76
+ }).catch(err=>tasksEl.replaceChildren(el('li',{class:'muted'},'Failed to load tasks: '+err.message)));
77
+ }
78
+
79
+ function openForm(t){
80
+ formEl.replaceChildren(el('p',{class:'muted'},'loading form…'));
81
+ const q='formKey='+encodeURIComponent(t.formKey);
82
+ api('/api/form?'+q).then(f=>{
83
+ formEl.replaceChildren();
84
+ if(!f||!f.schema){renderNoForm(t);return;}
85
+ renderForm(t,f.schema);
86
+ }).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
87
+ }
88
+
89
+ function renderNoForm(t){
90
+ formEl.appendChild(el('p',{class:'muted'},'This task has no renderable form.'));
91
+ const bar=el('div',{});
92
+ bar.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
93
+ bar.appendChild(btn('Cancel',loadTasks));
94
+ formEl.appendChild(bar);
95
+ }
96
+
97
+ function renderForm(t,schema){
98
+ const form=document.createElement('form');
99
+ form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
100
+ const components=Array.isArray(schema.components)?schema.components:[];
101
+ const inputs=[];
102
+ for(const c of components){
103
+ const built=buildField(c);
104
+ if(!built)continue;
105
+ form.appendChild(built.field);
106
+ if(built.read)inputs.push(built.read);
107
+ }
108
+ const submit=btn('Submit',null);submit.type='submit';
109
+ const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
110
+ form.appendChild(bar);
111
+ form.addEventListener('submit',e=>{
112
+ e.preventDefault();
113
+ // Null-prototype: field keys come from the engine-supplied form schema. A component
114
+ // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
115
+ // through JSON), never mutate a prototype — no prototype pollution in the page runtime.
116
+ const variables=Object.create(null);
117
+ for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
118
+ submit.disabled=true;
119
+ complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
120
+ });
121
+ formEl.appendChild(form);
122
+ }
123
+
124
+ function buildField(c){
125
+ const type=c&&c.type;
126
+ // Static (keyless) components: show text as a paragraph, ignore layout-only ones.
127
+ if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
128
+ const key=c&&c.key;
129
+ if(!key)return null;
130
+ const wrap=el('div',{class:'field'});
131
+ const label=typeof c.label==='string'&&c.label?c.label:key;
132
+ if(type==='checkbox'){
133
+ const input=document.createElement('input');input.type='checkbox';
134
+ const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
135
+ wrap.appendChild(lab);
136
+ return {field:wrap,read:()=>({key,value:input.checked})};
137
+ }
138
+ const labelEl=el('label',{},label);
139
+ wrap.appendChild(labelEl);
140
+ let input;
141
+ if(type==='textarea'){input=document.createElement('textarea');}
142
+ else if(type==='select'){
143
+ input=document.createElement('select');
144
+ input.appendChild(el('option',{value:''},'—'));
145
+ for(const o of (Array.isArray(c.values)?c.values:[])){
146
+ input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
147
+ }
148
+ }
149
+ else if(type==='radio'){
150
+ const name='r'+Math.random().toString(36).slice(2);
151
+ const group=el('div',{});
152
+ for(const o of (Array.isArray(c.values)?c.values:[])){
153
+ const rlab=el('label',{});
154
+ const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
155
+ rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
156
+ group.appendChild(rlab);
157
+ }
158
+ wrap.appendChild(group);
159
+ return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
160
+ }
161
+ else{
162
+ input=document.createElement('input');
163
+ input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
164
+ }
165
+ // Associate the label with the input (for/id) so screen readers announce it on focus.
166
+ const fieldId='f'+Math.random().toString(36).slice(2);
167
+ input.id=fieldId;labelEl.setAttribute('for',fieldId);
168
+ wrap.appendChild(input);
169
+ const isNumber=type==='number';
170
+ return {field:wrap,read:()=>{
171
+ const raw=input.value;
172
+ if(raw===''||raw==null)return null;
173
+ if(isNumber){
174
+ const n=Number(raw);
175
+ // A non-numeric value in a number field is only reachable via tampering (the browser
176
+ // blanks invalid type=number input). Treat it as absent rather than silently submitting
177
+ // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
178
+ return Number.isFinite(n)?{key,value:n}:null;
179
+ }
180
+ return {key,value:raw};
181
+ }};
182
+ }
183
+
184
+ function complete(userTaskKey,variables){
185
+ return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
186
+ body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
187
+ }
188
+
189
+ function el(tag,attrs,text){const e=document.createElement(tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(text!=null)e.textContent=text;return e;}
190
+ function btn(label,onClick){const b=document.createElement('button');b.type='button';b.textContent=label;if(onClick)b.addEventListener('click',onClick);return b;}
191
+
192
+ loadTasks();
42
193
  </script>`;
43
194
  }
44
195
 
@@ -56,7 +207,7 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
56
207
  method: "GET",
57
208
  path: base,
58
209
  source: "surface:taskInbox",
59
- handler: () => html(inboxPage(base)),
210
+ handler: () => html(inboxPage()),
60
211
  });
61
212
  routes.push({
62
213
  method: "GET",
@@ -64,10 +215,39 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
64
215
  source: "surface:taskInbox",
65
216
  handler: async (req) => {
66
217
  const pik = req.query.get("processInstanceKey") ?? undefined;
67
- const tasks = await app.engine.searchUserTasks(pik ? { processInstanceKey: pik } : undefined);
218
+ // Constrain the inbox to open (answerable) tasks. Without a state filter the engine
219
+ // returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
220
+ // or withdrawn tasks would surface as if actionable — completing one then fails with
221
+ // "User task ... is not active". See nanobpm/nano-ide#248.
222
+ const tasks = await app.engine.searchUserTasks({
223
+ state: "CREATED",
224
+ ...(pik ? { processInstanceKey: pik } : {}),
225
+ });
68
226
  return json(tasks);
69
227
  },
70
228
  });
229
+ routes.push({
230
+ method: "GET",
231
+ path: `${base}/api/form`,
232
+ source: "surface:taskInbox",
233
+ handler: async (req) => {
234
+ const formKey = req.query.get("formKey") ?? undefined;
235
+ const formId = req.query.get("formId") ?? undefined;
236
+ // Reject only when *neither* identifier is present. Presence follows getForm's
237
+ // canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
238
+ // whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
239
+ // truthiness check and returning a spurious 204. The raw values are still passed
240
+ // through unchanged — resolving a blank key to its `formId` fallback is getForm's
241
+ // single responsibility.
242
+ if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
243
+ return json({ error: "formKey or formId required" }, 400);
244
+ const form = await app.engine.getForm({ formKey, formId });
245
+ // A task whose form can't be resolved returns 204: the client renders the
246
+ // no-form fallback rather than erroring.
247
+ if (!form) return noContent();
248
+ return json(form);
249
+ },
250
+ });
71
251
  routes.push({
72
252
  method: "POST",
73
253
  path: `${base}/api/complete`,
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
30
30
  this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
31
31
  }
32
32
  async searchUserTasks(): Promise<[]> { return []; }
33
+ async getForm(): Promise<null> { return null; }
33
34
  async completeUserTask(): Promise<void> {}
34
35
  async searchProcessInstances(): Promise<[]> { return []; }
35
36
  async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
@@ -15,6 +15,7 @@ class MiniEngine implements EngineClient {
15
15
  async cancelInstance(): Promise<void> {}
16
16
  async publishMessage(): Promise<void> {}
17
17
  async searchUserTasks(): Promise<[]> { return []; }
18
+ async getForm(): Promise<null> { return null; }
18
19
  async completeUserTask(): Promise<void> {}
19
20
  async searchProcessInstances(): Promise<[]> { return []; }
20
21
  async close(): Promise<void> {}