@nanobpm/urban 0.47.0 → 0.49.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.
Files changed (81) 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 +71 -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 +77 -5
  16. package/dist/runtime/core/modules/surfaces.js +192 -18
  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 +9 -6
  23. package/dist/runtime/engine/nanosdk.js +65 -1
  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/derivers/shapes.d.ts +5 -0
  28. package/dist/toolkit/derivers/shapes.js +90 -0
  29. package/dist/toolkit/derivers/worker-io.d.ts +25 -0
  30. package/dist/toolkit/derivers/worker-io.js +104 -11
  31. package/dist/toolkit/fsio.js +9 -0
  32. package/dist/toolkit/gen.d.ts +26 -1
  33. package/dist/toolkit/gen.js +103 -22
  34. package/dist/toolkit/models.d.ts +6 -4
  35. package/dist/toolkit/models.js +7 -4
  36. package/package.json +1 -1
  37. package/src/runtime/adapters/deno.ts +26 -6
  38. package/src/runtime/adapters/node.test.ts +67 -0
  39. package/src/runtime/adapters/node.ts +37 -4
  40. package/src/runtime/core/context.ts +0 -4
  41. package/src/runtime/core/glob.ts +24 -0
  42. package/src/runtime/core/host.ts +74 -4
  43. package/src/runtime/core/manifest.test.ts +61 -1
  44. package/src/runtime/core/manifest.ts +136 -2
  45. package/src/runtime/core/modules/actions.test.ts +1 -0
  46. package/src/runtime/core/modules/api.test.ts +1 -0
  47. package/src/runtime/core/modules/cancel.test.ts +1 -0
  48. package/src/runtime/core/modules/connectors.test.ts +1 -0
  49. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  50. package/src/runtime/core/modules/deploy.test.ts +97 -161
  51. package/src/runtime/core/modules/deploy.ts +69 -37
  52. package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
  53. package/src/runtime/core/modules/instance-tracking.ts +19 -6
  54. package/src/runtime/core/modules/pages.test.ts +71 -3
  55. package/src/runtime/core/modules/pages.ts +77 -5
  56. package/src/runtime/core/modules/surfaces.test.ts +198 -5
  57. package/src/runtime/core/modules/surfaces.ts +191 -18
  58. package/src/runtime/core/modules/triggers.test.ts +1 -0
  59. package/src/runtime/core/modules/workers.test.ts +1 -0
  60. package/src/runtime/core/router.test.ts +10 -1
  61. package/src/runtime/core/router.ts +10 -0
  62. package/src/runtime/core/runtime.test.ts +67 -1
  63. package/src/runtime/core/runtime.ts +28 -9
  64. package/src/runtime/core/validate.test.ts +145 -0
  65. package/src/runtime/core/validate.ts +64 -1
  66. package/src/runtime/engine/nanosdk.test.ts +122 -0
  67. package/src/runtime/engine/nanosdk.ts +72 -2
  68. package/src/runtime/index.ts +11 -4
  69. package/src/runtime/run.ts +0 -1
  70. package/src/toolkit/derivers/shapes.test.ts +66 -0
  71. package/src/toolkit/derivers/shapes.ts +93 -0
  72. package/src/toolkit/derivers/worker-io.test.ts +131 -0
  73. package/src/toolkit/derivers/worker-io.ts +116 -9
  74. package/src/toolkit/fsio.ts +8 -0
  75. package/src/toolkit/gen.test.ts +81 -10
  76. package/src/toolkit/gen.ts +109 -22
  77. package/src/toolkit/models.test.ts +5 -5
  78. package/src/toolkit/models.ts +8 -5
  79. package/dist/runtime/core/modules/templates.d.ts +0 -18
  80. package/dist/runtime/core/modules/templates.js +0 -133
  81. package/src/runtime/core/modules/templates.ts +0 -153
@@ -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,204 @@ 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, { 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/form resolves a linked form's schema", async () => {
181
+ let seen: unknown;
182
+ const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name", label: "Name" }] };
183
+ const engine: EngineClient = {
184
+ ...fakeEngine,
185
+ getForm: async (input) => {
186
+ seen = input;
187
+ return { formKey: "form-123", schema };
188
+ },
189
+ };
190
+ const { form } = inboxRoutes(engine);
191
+ const res = await call(form, { query: "formKey=form-123" });
192
+ assert.deepEqual(seen, { formKey: "form-123", formId: undefined });
193
+ assert.equal(res.status, 200);
194
+ assert.deepEqual(JSON.parse(String(res.body)).schema, schema);
195
+ });
196
+
197
+ test("/api/form requires an identifier", async () => {
198
+ const { form } = inboxRoutes(fakeEngine);
199
+ const res = await call(form);
200
+ assert.equal(res.status, 400);
201
+ });
202
+
203
+ test("/api/form passes an empty formKey through so getForm can fall back to formId", async () => {
204
+ // A `?formKey=&formId=…` request must not be rejected at the boundary (only *both*
205
+ // identifiers absent is a 400) nor stripped here — resolving an empty key to its formId
206
+ // fallback is getForm's single responsibility (see nanosdk getForm empty/whitespace test).
207
+ let seen: unknown;
208
+ const engine: EngineClient = {
209
+ ...fakeEngine,
210
+ getForm: async (input) => {
211
+ seen = input;
212
+ return { formKey: "form-9", schema: { type: "default", components: [] } };
213
+ },
214
+ };
215
+ const { form } = inboxRoutes(engine);
216
+ const res = await call(form, { query: "formKey=&formId=form-9" });
217
+ assert.equal(res.status, 200);
218
+ assert.deepEqual(seen, { formKey: "", formId: "form-9" });
219
+ });
220
+
221
+ test("/api/form 400s a whitespace-only identifier instead of a spurious 204", async () => {
222
+ // A `?formKey= ` (or `formId= `) request provides no usable identifier. The route's
223
+ // presence gate must follow getForm's canonical rule (whitespace = absent) and 400,
224
+ // rather than letting the blank key slip past a raw truthiness check to getForm (→ null
225
+ // → 204). Guards the route↔getForm presence drift on the taskInbox form path — Red when
226
+ // the gate checks raw `!formKey && !formId`, since " " is truthy.
227
+ const { form } = inboxRoutes(fakeEngine);
228
+ assert.equal((await call(form, { query: "formKey=%20%20%20" })).status, 400);
229
+ assert.equal((await call(form, { query: "formId=%20%20" })).status, 400);
230
+ });
231
+
232
+ test("/api/form returns 204 for a task with no resolvable form (no-form fallback)", async () => {
233
+ const engine: EngineClient = { ...fakeEngine, getForm: async () => null };
234
+ const { form } = inboxRoutes(engine);
235
+ const res = await call(form, { query: "formKey=missing" });
236
+ assert.equal(res.status, 204);
237
+ // The body must be empty: a "null" payload (json(null, 204)) makes the client's
238
+ // fetch helper throw on parse and surface "Failed to load form" instead of the
239
+ // no-form fallback. Guard the wire shape here and the client short-circuit below.
240
+ assert.equal(res.body, "");
241
+ });
242
+
243
+ test("inbox client fetch helper short-circuits 204 so the no-form fallback renders", async () => {
244
+ const { page } = inboxRoutes(fakeEngine);
245
+ const rendered = String((await call(page)).body);
246
+ // The api() helper must not call r.json() on a 204 (empty body → throw). It has
247
+ // to resolve to null so openForm() falls through to renderNoForm(t).
248
+ assert.ok(rendered.includes("r.status===204"), "api() short-circuits a 204 response");
249
+ assert.ok(rendered.includes("function renderNoForm"), "page has a no-form renderer");
250
+ });
251
+
252
+ test("number field reader guards against a non-numeric (tampered) value", async () => {
253
+ // A number field submits `Number(raw)`; a non-numeric value (only reachable via tampering
254
+ // — the browser blanks invalid type=number input) yields NaN, which JSON.stringify
255
+ // serializes as null, silently changing the submission. The inline reader must guard it
256
+ // with Number.isFinite so a bad value is treated as absent rather than submitted as NaN.
257
+ const { page } = inboxRoutes(fakeEngine);
258
+ const rendered = String((await call(page)).body);
259
+ assert.ok(rendered.includes("Number.isFinite"), "number field guards NaN via Number.isFinite");
260
+ });
261
+
262
+ test("/api/complete completes the task with the submitted variables", async () => {
263
+ const calls: { key: string; variables?: Record<string, unknown> }[] = [];
264
+ const engine: EngineClient = {
265
+ ...fakeEngine,
266
+ completeUserTask: async (key, variables) => {
267
+ calls.push({ key, variables });
268
+ },
269
+ };
270
+ const { complete } = inboxRoutes(engine);
271
+ const res = await call(complete, { method: "POST", body: JSON.stringify({ userTaskKey: "7", variables: { name: "Ada", agree: true } }) });
272
+ assert.equal(res.status, 200);
273
+ assert.deepEqual(calls, [{ key: "7", variables: { name: "Ada", agree: true } }]);
274
+ });
275
+
276
+ test("/api/complete rejects a body with no userTaskKey", async () => {
277
+ const { complete } = inboxRoutes(fakeEngine);
278
+ const res = await call(complete, { method: "POST", body: JSON.stringify({ variables: {} }) });
279
+ assert.equal(res.status, 400);
280
+ });
281
+
@@ -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",
@@ -68,6 +219,28 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
68
219
  return json(tasks);
69
220
  },
70
221
  });
222
+ routes.push({
223
+ method: "GET",
224
+ path: `${base}/api/form`,
225
+ source: "surface:taskInbox",
226
+ handler: async (req) => {
227
+ const formKey = req.query.get("formKey") ?? undefined;
228
+ const formId = req.query.get("formId") ?? undefined;
229
+ // Reject only when *neither* identifier is present. Presence follows getForm's
230
+ // canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
231
+ // whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
232
+ // truthiness check and returning a spurious 204. The raw values are still passed
233
+ // through unchanged — resolving a blank key to its `formId` fallback is getForm's
234
+ // single responsibility.
235
+ if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
236
+ return json({ error: "formKey or formId required" }, 400);
237
+ const form = await app.engine.getForm({ formKey, formId });
238
+ // A task whose form can't be resolved returns 204: the client renders the
239
+ // no-form fallback rather than erroring.
240
+ if (!form) return noContent();
241
+ return json(form);
242
+ },
243
+ });
71
244
  routes.push({
72
245
  method: "POST",
73
246
  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> {}
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { normalizeRoutePath } from "./router.ts";
3
+ import { noContent, normalizeRoutePath } from "./router.ts";
4
4
 
5
5
  test("normalizeRoutePath ensures a leading slash and trims trailing slashes", () => {
6
6
  assert.equal(normalizeRoutePath("hooks/x", "/fallback"), "/hooks/x");
@@ -20,3 +20,12 @@ test("normalizeRoutePath falls back when value is missing or collapses to root",
20
20
  assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
21
21
  assert.equal(normalizeRoutePath("", "/tasks"), "/tasks");
22
22
  });
23
+
24
+ test("noContent emits a 204 with an empty body and no content-type", () => {
25
+ const res = noContent();
26
+ assert.equal(res.status, 204);
27
+ // 204 must carry no body: a "null" (or any) payload violates the semantics and
28
+ // makes a body-parsing client throw. Guard against a json(null, 204) regression.
29
+ assert.equal(res.body, "");
30
+ assert.equal(res.headers?.["content-type"], undefined);
31
+ });
@@ -26,6 +26,16 @@ export function text(body: string, status = 200): HttpResponse {
26
26
  return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
27
27
  }
28
28
 
29
+ /**
30
+ * A `204 No Content` response: an empty body and no `content-type`. Use this
31
+ * instead of `json(null, 204)` — the latter emits a `"null"` body that both
32
+ * violates the no-body semantics of 204 and makes a client that parses the
33
+ * body throw. Any client that short-circuits 204 receives no payload here.
34
+ */
35
+ export function noContent(): HttpResponse {
36
+ return { status: 204, headers: {}, body: "" };
37
+ }
38
+
29
39
  /**
30
40
  * Normalize a manifest-supplied route path: collapse leading slashes to exactly
31
41
  * one, drop trailing slashes. Incoming request paths always start with "/", so a
@@ -41,6 +41,9 @@ class FakeEngine implements EngineClient {
41
41
  async searchUserTasks() {
42
42
  return this.userTasks;
43
43
  }
44
+ async getForm() {
45
+ return null;
46
+ }
44
47
  async completeUserTask(key: string, variables?: Record<string, unknown>) {
45
48
  this.completedTasks.push({ key, variables });
46
49
  }
@@ -70,7 +73,7 @@ function expectArray(value: unknown): unknown[] {
70
73
  return value;
71
74
  }
72
75
 
73
- async function makeFixture(): Promise<string> {
76
+ async function makeFixture(extraManifest?: Record<string, unknown>): Promise<string> {
74
77
  const dir = await mkdtemp(join(tmpdir(), "urban-rt-"));
75
78
  await mkdir(join(dir, "processes"));
76
79
  await mkdir(join(dir, "decisions"));
@@ -103,6 +106,7 @@ async function makeFixture(): Promise<string> {
103
106
  workers: [{ taskType: "wf.claim", handler: "workers/handlers.ts" }],
104
107
  triggers: [{ id: "hook", type: "webhook", path: "/hooks/task", action: { message: "wf.requested", correlationKey: "= body.taskId" } }],
105
108
  surfaces: { taskInbox: { enabled: true, path: "/tasks" } },
109
+ ...extraManifest,
106
110
  };
107
111
  await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2));
108
112
  return dir;
@@ -569,6 +573,68 @@ test("treats NANOBPMN_APP_HANDSHAKE other than \"1\" as opt-out", async () => {
569
573
  }
570
574
  });
571
575
 
576
+ // Read the interface a started app's HTTP server actually bound to (issue #235).
577
+ function appBoundAddress(app: { httpServer: object | undefined }): string {
578
+ const native = app.httpServer;
579
+ assert.ok(native instanceof http.Server, "app exposes a node http.Server");
580
+ const addr = native.address();
581
+ assert.ok(addr && typeof addr === "object", "server has a bound AddressInfo");
582
+ return addr.address;
583
+ }
584
+
585
+ test("defaults to a loopback bind when the manifest omits network (issue #235)", async () => {
586
+ const dir = await makeFixture();
587
+ const host = createNodeHost({ cwd: dir, log: () => {} });
588
+ const engine = new FakeEngine();
589
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
590
+ await app.start();
591
+ try {
592
+ assert.equal(appBoundAddress(app), "127.0.0.1");
593
+ } finally {
594
+ await app.stop();
595
+ await rm(dir, { recursive: true, force: true });
596
+ }
597
+ });
598
+
599
+ test('manifest network.bind:"all" binds every interface (issue #235)', async () => {
600
+ const dir = await makeFixture({ network: { bind: "all" } });
601
+ const logs: Array<{ level: string; msg: string }> = [];
602
+ const host = createNodeHost({ cwd: dir, log: (level, msg) => logs.push({ level, msg }) });
603
+ const engine = new FakeEngine();
604
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
605
+ await app.start();
606
+ try {
607
+ assert.equal(appBoundAddress(app), "0.0.0.0");
608
+ // exposing the app off-box must never be silent
609
+ assert.ok(
610
+ logs.some((l) => l.level === "warn" && l.msg.includes("all interfaces")),
611
+ "a warning is logged when binding to all interfaces",
612
+ );
613
+ } finally {
614
+ await app.stop();
615
+ await rm(dir, { recursive: true, force: true });
616
+ }
617
+ });
618
+
619
+ test("URBAN_BIND=all overrides a loopback manifest (issue #235)", async () => {
620
+ const dir = await makeFixture({ network: { bind: "loopback" } });
621
+ const host = createNodeHost({ cwd: dir, log: () => {} });
622
+ const engine = new FakeEngine();
623
+ const prev = process.env.URBAN_BIND;
624
+ process.env.URBAN_BIND = "all";
625
+ let app: Awaited<ReturnType<typeof createUrbanApp>> | undefined;
626
+ try {
627
+ app = await createUrbanApp({ host, engine, root: ".", port: 0 });
628
+ await app.start();
629
+ assert.equal(appBoundAddress(app), "0.0.0.0");
630
+ } finally {
631
+ if (prev === undefined) delete process.env.URBAN_BIND;
632
+ else process.env.URBAN_BIND = prev;
633
+ await app?.stop();
634
+ await rm(dir, { recursive: true, force: true });
635
+ }
636
+ });
637
+
572
638
  // A minimal deterministic scheduler seam: records armed timers so a test can assert the
573
639
  // injected scheduler — not the live one — drives createUrbanApp's background loops.
574
640
  function countingScheduler(): SchedulerDeps & { armed: () => number } {