@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
@@ -1,7 +1,13 @@
1
1
  import type { RuntimeContext } from "../context.ts";
2
- /** Deploy all processes, decisions and forms declared under `models`. Each resource has its
3
- * `{{name}}` placeholders substituted (manifest `models.templates`, then the programmatic
4
- * `ctx.templates`, the latter winning) before it is sent to the engine. */
2
+ /** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
3
+ export declare const RESOURCES_DIR = "resources";
4
+ /**
5
+ * Deploy the app's processes, decisions and forms.
6
+ *
7
+ * With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
8
+ * one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
9
+ * hard error in either mode.
10
+ */
5
11
  export declare function deployModels(ctx: RuntimeContext): Promise<{
6
12
  deployed: number;
7
13
  files: string[];
@@ -1,7 +1,19 @@
1
- // deploy — resolve the manifest `models` patterns and deploy the resources to the engine,
2
- // substituting any `{{name}}` templates into each resource first (see templates.ts).
3
- import { expandPatterns } from "../glob.js";
4
- import { applyTemplates, resolveTemplates } from "./templates.js";
1
+ // deploy — resolve the app's deployable resources and send them to the engine.
2
+ //
3
+ // Resources are found by ONE of two paths:
4
+ // 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
5
+ // discovered by walking `resources/` — shallow, one level deep. `resources/` is deploy-only:
6
+ // everything under it (and nothing outside it) is deployed. Docs (`docs/`, `AGENTS.md`,
7
+ // top-level `*.md`) live outside `resources/` and are never swept in.
8
+ // 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
9
+ // convention walk is skipped) — the escape hatch for a non-convention layout.
10
+ //
11
+ // Either way the deploy dedupe key is the resource's basename (filename only), so a basename
12
+ // collision — two files with the same name in different directories — would silently clobber one
13
+ // resource with another at the engine. We detect that up front and fail loudly instead.
14
+ import { discoverResources, expandPatterns } from "../glob.js";
15
+ /** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
16
+ export const RESOURCES_DIR = "resources";
5
17
  function contentTypeFor(path) {
6
18
  if (path.endsWith(".bpmn"))
7
19
  return "text/xml";
@@ -15,45 +27,65 @@ function baseName(path) {
15
27
  const i = path.lastIndexOf("/");
16
28
  return i >= 0 ? path.slice(i + 1) : path;
17
29
  }
18
- /** Deploy all processes, decisions and forms declared under `models`. Each resource has its
19
- * `{{name}}` placeholders substituted (manifest `models.templates`, then the programmatic
20
- * `ctx.templates`, the latter winning) before it is sent to the engine. */
30
+ /** Fail loudly when two resolved files share a basename (the deploy dedupe key), naming the
31
+ * colliding paths so the author can rename or relocate one. */
32
+ function assertNoBasenameCollisions(files) {
33
+ const byName = new Map();
34
+ for (const path of files) {
35
+ const name = baseName(path);
36
+ const list = byName.get(name);
37
+ if (list)
38
+ list.push(path);
39
+ else
40
+ byName.set(name, [path]);
41
+ }
42
+ const collisions = [...byName.entries()].filter(([, paths]) => paths.length > 1);
43
+ if (collisions.length === 0)
44
+ return;
45
+ const detail = collisions
46
+ .map(([name, paths]) => ` ${name}: ${paths.slice().sort().join(", ")}`)
47
+ .join("\n");
48
+ throw new Error("deploy: basename collision — resources are deployed keyed by filename, so two files with " +
49
+ `the same name would clobber each other. Rename or relocate one of:\n${detail}`);
50
+ }
51
+ /**
52
+ * Deploy the app's processes, decisions and forms.
53
+ *
54
+ * With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
55
+ * one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
56
+ * hard error in either mode.
57
+ */
21
58
  export async function deployModels(ctx) {
22
- const models = ctx.manifest.models ?? {};
59
+ const models = ctx.manifest.models;
23
60
  const patterns = [
24
- ...(models.processes ?? []),
25
- ...(models.decisions ?? []),
26
- ...(models.forms ?? []),
61
+ ...(models?.processes ?? []),
62
+ ...(models?.decisions ?? []),
63
+ ...(models?.forms ?? []),
27
64
  ];
28
- const files = await expandPatterns(ctx.host, ctx.root, patterns);
65
+ // Convention is keyed off the *absence* of the `models` block, not an empty pattern set: a
66
+ // declared `models` (even one resolving to zero files) is an explicit override that must NOT
67
+ // silently fall back to the `resources/` walk (ADR 0062: "no `models` block → convention;
68
+ // `models` present → exact override").
69
+ const byConvention = models === undefined;
70
+ const files = byConvention
71
+ ? await discoverResources(ctx.host, ctx.root, RESOURCES_DIR)
72
+ : await expandPatterns(ctx.host, ctx.root, patterns);
29
73
  if (files.length === 0) {
30
- ctx.host.log("info", "deploy: no model files matched", { patterns });
74
+ if (byConvention) {
75
+ ctx.host.log("info", "deploy: no resources found by convention", { dir: RESOURCES_DIR });
76
+ }
77
+ else {
78
+ ctx.host.log("info", "deploy: no model files matched", { patterns });
79
+ }
31
80
  return { deployed: 0, files: [] };
32
81
  }
33
- // Resolve templates once (manifest globs/dirs, then the programmatic deploy option). An empty
34
- // map makes substitution a no-op, so apps that use no templates are unaffected.
35
- const templates = await resolveTemplates(ctx.host, ctx.root, [models.templates, ctx.templates]);
36
- const hasTemplates = Object.keys(templates).length > 0;
37
- const resources = await Promise.all(files.map(async (path) => {
38
- const contentType = contentTypeFor(path);
39
- let content = await ctx.host.readTextFile(path);
40
- // Only substitute into the model content types we know how to escape (XML for .bpmn/.dmn,
41
- // JSON for .form). Unknown extensions map to application/octet-stream; running the default
42
- // XML escaper over them could deterministically corrupt a non-XML/JSON resource that a
43
- // manifest pattern matched by accident, so leave those files untouched.
44
- if (hasTemplates && contentType !== "application/octet-stream") {
45
- const applied = applyTemplates(content, contentType, templates);
46
- content = applied.content;
47
- if (applied.unresolved.length > 0) {
48
- ctx.host.log("warn", "deploy: unresolved template placeholders", {
49
- file: path,
50
- unresolved: applied.unresolved,
51
- });
52
- }
53
- }
54
- return { name: baseName(path), content, contentType };
55
- }));
82
+ assertNoBasenameCollisions(files);
83
+ const resources = await Promise.all(files.map(async (path) => ({
84
+ name: baseName(path),
85
+ content: await ctx.host.readTextFile(path),
86
+ contentType: contentTypeFor(path),
87
+ })));
56
88
  const { deployed } = await ctx.engine.deployResources(resources);
57
- ctx.host.log("info", "deploy: models deployed", { deployed, files });
89
+ ctx.host.log("info", "deploy: resources deployed", { deployed, files, byConvention });
58
90
  return { deployed, files };
59
91
  }
@@ -1,5 +1,5 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
- import type { InstanceTracking } from "../manifest.ts";
2
+ import { type InstanceTracking } from "../manifest.ts";
3
3
  import { type SchedulerDeps } from "./scheduler.ts";
4
4
  /** Default poll interval when a binding does not set `pollMs`. */
5
5
  export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
@@ -12,6 +12,7 @@
12
12
  // — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
13
13
  // (reconciling it here would race that worker and could clobber a legitimately-completed
14
14
  // row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
15
+ import { isConfiguredStatusSelector } from "../manifest.js";
15
16
  import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
16
17
  /** Default poll interval when a binding does not set `pollMs`. */
17
18
  export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
@@ -44,13 +45,27 @@ export async function reconcileTerminatedKey(api, bindings, processInstanceKey)
44
45
  * scanned and how many rows were reconciled this tick. */
45
46
  async function reconcileOnce(api, binding) {
46
47
  const table = api.data.table(binding.table, binding.keyField);
47
- // Select the rows worth polling. With a statusField + activeStatuses, only rows in an
48
- // active status are candidates, so a row already in a terminal status is skipped; without
49
- // them every row is polled and a terminal instance's row is idempotently re-patched (the
50
- // engine query returns only TERMINATED instances and the patch is a no-op once applied).
48
+ // Select the rows worth polling. Three modes, in precedence order:
49
+ // - `terminalStatuses` (fail-open exclusion): poll every row whose `statusField` is NOT
50
+ // a terminal value. A newly-added non-terminal status is polled by default, so it can't
51
+ // silently fall out of reconciliation; only already-terminal rows are excluded (bounded).
52
+ // - `activeStatuses` (fail-closed allow-list): poll only rows in an enumerated active status.
53
+ // - neither: poll every row; a terminal instance's row is idempotently re-patched (the engine
54
+ // query returns only TERMINATED instances and the patch is a no-op once applied).
55
+ // `terminalStatuses` and `activeStatuses` are mutually exclusive per binding (validation rejects
56
+ // both); if both are somehow present, `terminalStatuses` wins (the fail-open selector). Each
57
+ // selector is gated through the shared `isConfiguredStatusSelector` predicate (a non-empty array
58
+ // of non-empty strings) — the same one validation uses — so a malformed value (a bare string, an
59
+ // empty array) degrades to the fail-open poll-all path here instead of `new Set("abandoned")`
60
+ // silently filtering by character, or a non-array crashing `activeStatuses.map(...)`.
51
61
  let candidates;
52
62
  const statusField = binding.statusField;
53
- if (statusField && binding.activeStatuses && binding.activeStatuses.length > 0) {
63
+ if (statusField && isConfiguredStatusSelector(binding.terminalStatuses)) {
64
+ const terminal = new Set(binding.terminalStatuses);
65
+ const all = await table.all();
66
+ candidates = all.filter((row) => !terminal.has(String(row[statusField])));
67
+ }
68
+ else if (statusField && isConfiguredStatusSelector(binding.activeStatuses)) {
54
69
  const perStatus = await Promise.all(binding.activeStatuses.map((s) => table.find({ [statusField]: s })));
55
70
  candidates = perStatus.flat();
56
71
  }
@@ -513,6 +513,9 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
513
513
  .pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
514
514
  .pc-badge-ok { background:var(--nano-ok); }
515
515
  .pc-badge-info { background:var(--nano-info); }
516
+ /* Live row-count pill (dataGrid showCount): a subtle, neutral count distinct from
517
+ the coloured status badges, legible in the collapse header while collapsed. */
518
+ .pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
516
519
  :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
517
520
  @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
518
521
  .pc-child { margin:.6rem 0; }
@@ -1214,10 +1217,25 @@ function renderActionForm(node) {
1214
1217
  return card;
1215
1218
  }
1216
1219
 
1220
+ // Accessible name for the row-count badge. The visible text is just a number, so an
1221
+ // aria-label would override it with a bare "row count" (the count — the important part —
1222
+ // is dropped). Instead name it "N row(s)" and keep it in sync with the text on refresh.
1223
+ // Single source of truth for both the initial render and every refresh below.
1224
+ function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1225
+
1217
1226
  function renderDataGrid(node) {
1218
1227
  const p = node.props;
1219
1228
  const card = el("section", { class: "pc-card" });
1220
- if (p.title) card.append(el("h2", {}, p.title));
1229
+ // Opt-in live row-count badge ("showCount"). Rendered inside the <h2> so a plain
1230
+ // (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it
1231
+ // into the collapse header so it stays visible — and refresh-updated — while the
1232
+ // section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.
1233
+ const countBadge = p.showCount ? el("span", { class: "pc-badge pc-count-badge", "aria-label": rowCountLabel(0) }, "0") : null;
1234
+ if (p.title || countBadge) {
1235
+ const h2 = el("h2", {}, p.title || "");
1236
+ if (countBadge) { if (p.title) h2.append(" "); h2.append(countBadge); }
1237
+ card.append(h2);
1238
+ }
1221
1239
  const cols = p.columns || [];
1222
1240
  const tabs = p.tabs || [];
1223
1241
  const rowActions = p.rowActions || [];
@@ -1535,6 +1553,10 @@ function renderDataGrid(node) {
1535
1553
  const { rows } = paramScoped && PARAM === ""
1536
1554
  ? { rows: [] }
1537
1555
  : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1556
+ // Live row-count badge: reflect the currently-fetched, filtered (active-tab)
1557
+ // set — exactly what the body renders — on every refresh (poll + pc:refresh),
1558
+ // whether the section is expanded or collapsed.
1559
+ if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute("aria-label", rowCountLabel(rows.length)); }
1538
1560
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1539
1561
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1540
1562
  if (p.rowKey) {
@@ -1703,7 +1725,16 @@ function makeCollapsible(node, card) {
1703
1725
  const isCard = card.tagName === "SECTION";
1704
1726
  const container = isCard ? card : el("section", { class: "pc-card" });
1705
1727
  const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
1706
- const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
1728
+ // A dataGrid with "showCount" renders a live count badge inside its <h2>. Lift it
1729
+ // out before deriving the title (so the count doesn't leak into the label text),
1730
+ // then re-home it in the header alongside the title so it stays visible — and
1731
+ // refresh-updated by the grid's own refresh() — while the section is collapsed.
1732
+ const countBadge = h2 && h2.querySelector ? h2.querySelector(".pc-count-badge") : null;
1733
+ if (countBadge) countBadge.remove();
1734
+ // Trim the derived label: after the badge is lifted out, the leftover " " whitespace
1735
+ // node from h2.append(" ", countBadge) would otherwise make a title-less section's
1736
+ // header a lone space (and leak whitespace into the storage key). Fall back to "Section".
1737
+ const titleText = props.title || (h2 ? h2.textContent.trim() : "") || "Section";
1707
1738
  if (h2) h2.remove();
1708
1739
  const body = el("div", { class: "pc-card-body" });
1709
1740
  if (isCard) {
@@ -1718,6 +1749,7 @@ function makeCollapsible(node, card) {
1718
1749
  chevron,
1719
1750
  el("span", { class: "pc-collapse-title" }, titleText),
1720
1751
  );
1752
+ if (countBadge) header.append(countBadge);
1721
1753
  const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
1722
1754
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
1723
1755
  function apply() {
@@ -1,8 +1,13 @@
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.
5
- import { html, json, normalizeRoutePath } from "../router.js";
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.
9
+ import { presentFormIdentifier } from "../host.js";
10
+ import { html, json, noContent, normalizeRoutePath } from "../router.js";
6
11
  import { mountActions } from "./actions.js";
7
12
  import { mountApi } from "./api.js";
8
13
  import { mountPages } from "./pages.js";
@@ -10,23 +15,169 @@ import { mountPages } from "./pages.js";
10
15
  function escapeHtml(s) {
11
16
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
12
17
  }
13
- function inboxPage(basePath) {
18
+ function inboxPage() {
19
+ // Reverse-proxy safe: the client derives its API base from location.pathname
20
+ // (where THIS page was actually served) rather than an embedded absolute
21
+ // route base. A hardcoded "/tasks" would escape the Nano console's
22
+ // path-prefixed proxy (/console/app-view/<name>/tasks) — the API fetches must
23
+ // inherit that prefix. Deriving from location also removes the manifest→
24
+ // <script> injection surface entirely: nothing manifest-supplied is embedded.
25
+ // All task/form values are rendered via textContent / setAttribute — never
26
+ // innerHTML — so nothing the engine returns can inject markup.
14
27
  return `<!doctype html><meta charset="utf-8"><title>Task inbox</title>
15
28
  <style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:52rem}
16
- li{margin:.4rem 0}code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}</style>
29
+ ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
30
+ code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
31
+ button{font:inherit;padding:.2rem .6rem;cursor:pointer}
32
+ form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
33
+ form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
34
+ form .field input[type=checkbox],form .field input[type=radio]{width:auto}
35
+ #form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
36
+ .muted{color:#666}</style>
17
37
  <h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
38
+ <div id="form"></div>
18
39
  <script>
19
- fetch(${JSON.stringify(basePath)}+'/api/tasks').then(r=>r.json()).then(ts=>{
20
- const ul=document.getElementById('tasks');
21
- ul.replaceChildren();
22
- if(!ts.length){const li=document.createElement('li');li.textContent='No open tasks.';ul.appendChild(li);return;}
23
- for(const t of ts){const li=document.createElement('li');
24
- const code=document.createElement('code');
25
- code.textContent=t.elementId||t.userTaskKey;
26
- li.appendChild(code);
27
- li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
28
- ul.appendChild(li);}
29
- });
40
+ const BASE=location.pathname.replace(/\/+$/,'');
41
+ const tasksEl=document.getElementById('tasks');
42
+ const formEl=document.getElementById('form');
43
+
44
+ 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();});}
45
+
46
+ function loadTasks(){
47
+ formEl.replaceChildren();
48
+ tasksEl.replaceChildren(el('li',{},'loading…'));
49
+ return api('/api/tasks').then(ts=>{
50
+ tasksEl.replaceChildren();
51
+ if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
52
+ for(const t of ts){
53
+ const li=document.createElement('li');
54
+ const code=el('code',{},t.elementId||t.userTaskKey);
55
+ li.appendChild(code);
56
+ li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
57
+ if(t.formKey){
58
+ li.appendChild(btn('Open',()=>openForm(t)));
59
+ }else{
60
+ li.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
61
+ }
62
+ tasksEl.appendChild(li);
63
+ }
64
+ }).catch(err=>tasksEl.replaceChildren(el('li',{class:'muted'},'Failed to load tasks: '+err.message)));
65
+ }
66
+
67
+ function openForm(t){
68
+ formEl.replaceChildren(el('p',{class:'muted'},'loading form…'));
69
+ const q='formKey='+encodeURIComponent(t.formKey);
70
+ api('/api/form?'+q).then(f=>{
71
+ formEl.replaceChildren();
72
+ if(!f||!f.schema){renderNoForm(t);return;}
73
+ renderForm(t,f.schema);
74
+ }).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
75
+ }
76
+
77
+ function renderNoForm(t){
78
+ formEl.appendChild(el('p',{class:'muted'},'This task has no renderable form.'));
79
+ const bar=el('div',{});
80
+ bar.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
81
+ bar.appendChild(btn('Cancel',loadTasks));
82
+ formEl.appendChild(bar);
83
+ }
84
+
85
+ function renderForm(t,schema){
86
+ const form=document.createElement('form');
87
+ form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
88
+ const components=Array.isArray(schema.components)?schema.components:[];
89
+ const inputs=[];
90
+ for(const c of components){
91
+ const built=buildField(c);
92
+ if(!built)continue;
93
+ form.appendChild(built.field);
94
+ if(built.read)inputs.push(built.read);
95
+ }
96
+ const submit=btn('Submit',null);submit.type='submit';
97
+ const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
98
+ form.appendChild(bar);
99
+ form.addEventListener('submit',e=>{
100
+ e.preventDefault();
101
+ // Null-prototype: field keys come from the engine-supplied form schema. A component
102
+ // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
103
+ // through JSON), never mutate a prototype — no prototype pollution in the page runtime.
104
+ const variables=Object.create(null);
105
+ for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
106
+ submit.disabled=true;
107
+ complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
108
+ });
109
+ formEl.appendChild(form);
110
+ }
111
+
112
+ function buildField(c){
113
+ const type=c&&c.type;
114
+ // Static (keyless) components: show text as a paragraph, ignore layout-only ones.
115
+ if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
116
+ const key=c&&c.key;
117
+ if(!key)return null;
118
+ const wrap=el('div',{class:'field'});
119
+ const label=typeof c.label==='string'&&c.label?c.label:key;
120
+ if(type==='checkbox'){
121
+ const input=document.createElement('input');input.type='checkbox';
122
+ const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
123
+ wrap.appendChild(lab);
124
+ return {field:wrap,read:()=>({key,value:input.checked})};
125
+ }
126
+ const labelEl=el('label',{},label);
127
+ wrap.appendChild(labelEl);
128
+ let input;
129
+ if(type==='textarea'){input=document.createElement('textarea');}
130
+ else if(type==='select'){
131
+ input=document.createElement('select');
132
+ input.appendChild(el('option',{value:''},'—'));
133
+ for(const o of (Array.isArray(c.values)?c.values:[])){
134
+ input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
135
+ }
136
+ }
137
+ else if(type==='radio'){
138
+ const name='r'+Math.random().toString(36).slice(2);
139
+ const group=el('div',{});
140
+ for(const o of (Array.isArray(c.values)?c.values:[])){
141
+ const rlab=el('label',{});
142
+ const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
143
+ rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
144
+ group.appendChild(rlab);
145
+ }
146
+ wrap.appendChild(group);
147
+ return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
148
+ }
149
+ else{
150
+ input=document.createElement('input');
151
+ input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
152
+ }
153
+ // Associate the label with the input (for/id) so screen readers announce it on focus.
154
+ const fieldId='f'+Math.random().toString(36).slice(2);
155
+ input.id=fieldId;labelEl.setAttribute('for',fieldId);
156
+ wrap.appendChild(input);
157
+ const isNumber=type==='number';
158
+ return {field:wrap,read:()=>{
159
+ const raw=input.value;
160
+ if(raw===''||raw==null)return null;
161
+ if(isNumber){
162
+ const n=Number(raw);
163
+ // A non-numeric value in a number field is only reachable via tampering (the browser
164
+ // blanks invalid type=number input). Treat it as absent rather than silently submitting
165
+ // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
166
+ return Number.isFinite(n)?{key,value:n}:null;
167
+ }
168
+ return {key,value:raw};
169
+ }};
170
+ }
171
+
172
+ function complete(userTaskKey,variables){
173
+ return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
174
+ body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
175
+ }
176
+
177
+ 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;}
178
+ function btn(label,onClick){const b=document.createElement('button');b.type='button';b.textContent=label;if(onClick)b.addEventListener('click',onClick);return b;}
179
+
180
+ loadTasks();
30
181
  </script>`;
31
182
  }
32
183
  /** Mount the enabled surfaces and return their routes. */
@@ -42,7 +193,7 @@ export function mountSurfaces(ctx, app) {
42
193
  method: "GET",
43
194
  path: base,
44
195
  source: "surface:taskInbox",
45
- handler: () => html(inboxPage(base)),
196
+ handler: () => html(inboxPage()),
46
197
  });
47
198
  routes.push({
48
199
  method: "GET",
@@ -50,10 +201,40 @@ export function mountSurfaces(ctx, app) {
50
201
  source: "surface:taskInbox",
51
202
  handler: async (req) => {
52
203
  const pik = req.query.get("processInstanceKey") ?? undefined;
53
- const tasks = await app.engine.searchUserTasks(pik ? { processInstanceKey: pik } : undefined);
204
+ // Constrain the inbox to open (answerable) tasks. Without a state filter the engine
205
+ // returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
206
+ // or withdrawn tasks would surface as if actionable — completing one then fails with
207
+ // "User task ... is not active". See nanobpm/nano-ide#248.
208
+ const tasks = await app.engine.searchUserTasks({
209
+ state: "CREATED",
210
+ ...(pik ? { processInstanceKey: pik } : {}),
211
+ });
54
212
  return json(tasks);
55
213
  },
56
214
  });
215
+ routes.push({
216
+ method: "GET",
217
+ path: `${base}/api/form`,
218
+ source: "surface:taskInbox",
219
+ handler: async (req) => {
220
+ const formKey = req.query.get("formKey") ?? undefined;
221
+ const formId = req.query.get("formId") ?? undefined;
222
+ // Reject only when *neither* identifier is present. Presence follows getForm's
223
+ // canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
224
+ // whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
225
+ // truthiness check and returning a spurious 204. The raw values are still passed
226
+ // through unchanged — resolving a blank key to its `formId` fallback is getForm's
227
+ // single responsibility.
228
+ if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
229
+ return json({ error: "formKey or formId required" }, 400);
230
+ const form = await app.engine.getForm({ formKey, formId });
231
+ // A task whose form can't be resolved returns 204: the client renders the
232
+ // no-form fallback rather than erroring.
233
+ if (!form)
234
+ return noContent();
235
+ return json(form);
236
+ },
237
+ });
57
238
  routes.push({
58
239
  method: "POST",
59
240
  path: `${base}/api/complete`,
@@ -11,6 +11,13 @@ export interface Route {
11
11
  export declare function json(body: unknown, status?: number): HttpResponse;
12
12
  export declare function html(body: string, status?: number): HttpResponse;
13
13
  export declare function text(body: string, status?: number): HttpResponse;
14
+ /**
15
+ * A `204 No Content` response: an empty body and no `content-type`. Use this
16
+ * instead of `json(null, 204)` — the latter emits a `"null"` body that both
17
+ * violates the no-body semantics of 204 and makes a client that parses the
18
+ * body throw. Any client that short-circuits 204 receives no payload here.
19
+ */
20
+ export declare function noContent(): HttpResponse;
14
21
  /**
15
22
  * Normalize a manifest-supplied route path: collapse leading slashes to exactly
16
23
  * one, drop trailing slashes. Incoming request paths always start with "/", so a
@@ -10,6 +10,15 @@ export function html(body, status = 200) {
10
10
  export function text(body, status = 200) {
11
11
  return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
12
12
  }
13
+ /**
14
+ * A `204 No Content` response: an empty body and no `content-type`. Use this
15
+ * instead of `json(null, 204)` — the latter emits a `"null"` body that both
16
+ * violates the no-body semantics of 204 and makes a client that parses the
17
+ * body throw. Any client that short-circuits 204 receives no payload here.
18
+ */
19
+ export function noContent() {
20
+ return { status: 204, headers: {}, body: "" };
21
+ }
13
22
  /**
14
23
  * Normalize a manifest-supplied route path: collapse leading slashes to exactly
15
24
  * one, drop trailing slashes. Incoming request paths always start with "/", so a
@@ -1,6 +1,5 @@
1
1
  import type { EngineClient, HostContext } from "./host.ts";
2
2
  import { type AppManifest } from "./manifest.ts";
3
- import type { TemplateSource } from "./modules/templates.ts";
4
3
  import { DataLayer } from "./modules/datasource.ts";
5
4
  import { type Logger } from "./logger.ts";
6
5
  import type { SchedulerDeps } from "./modules/scheduler.ts";
@@ -32,10 +31,6 @@ export interface CreateUrbanAppOptions {
32
31
  port?: number;
33
32
  /** Which modules to mount (all true by default). */
34
33
  mount?: MountFlags;
35
- /** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
36
- * `name → content` map). Merged over the manifest's `models.templates` (this wins on
37
- * collision). See `deployModels`. */
38
- templates?: TemplateSource;
39
34
  /**
40
35
  * Injectable timer + clock seam driving every background loop (the cron trigger loops
41
36
  * and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
@@ -2,7 +2,7 @@
2
2
  // materializes the app by mounting each module. Hosts (CLI/IDE/console/bare process) call
3
3
  // this the same way; a host may mount a subset via `mount` flags (e.g. the console mounts
4
4
  // its own surfaces but reuses deploy + workers + datasource).
5
- import { loadManifest } from "./manifest.js";
5
+ import { loadManifest, resolveBindMode, bindModeToHost } from "./manifest.js";
6
6
  import { validateManifest } from "./validate.js";
7
7
  import { makeRouter } from "./router.js";
8
8
  import { deployModels } from "./modules/deploy.js";
@@ -48,7 +48,7 @@ export async function createUrbanApp(opts) {
48
48
  security: opts.mount?.security ?? true,
49
49
  instanceTracking: opts.mount?.instanceTracking ?? true,
50
50
  };
51
- const ctx = { manifest, host, engine, root, templates: opts.templates };
51
+ const ctx = { manifest, host, engine, root };
52
52
  // One app-level structured logger over the host sink, shared by the entrypoint (exposed as
53
53
  // `app.log`) and the per-invocation `AppApi.log` (workers/route delegates `child()` it with their
54
54
  // correlation context).
@@ -65,6 +65,12 @@ export async function createUrbanApp(opts) {
65
65
  let started = false;
66
66
  let httpPort;
67
67
  const port = resolvePort(opts.port, host.env("PORT"));
68
+ // Which interface the HTTP server binds to (issue #235). Secure by default: loopback unless
69
+ // the manifest's `network.bind` (or the `URBAN_BIND` env override) opts into all interfaces.
70
+ // Derive the host address from the already-resolved mode so the two can never diverge (and we
71
+ // consult the env exactly once) — a second `resolveBindHost` call would re-read the override.
72
+ const bindMode = resolveBindMode(manifest, (n) => host.env(n));
73
+ const bindHost = bindModeToHost(bindMode);
68
74
  // Release every mounted resource and reset internal state so a subsequent
69
75
  // start() begins clean. Used by stop() and by start()'s failure path.
70
76
  const teardown = async () => {
@@ -181,9 +187,26 @@ export async function createUrbanApp(opts) {
181
187
  body: JSON.stringify({ ok: true, app: manifest.id }),
182
188
  }),
183
189
  });
184
- server = await host.serveHttp(port, makeRouter(routes));
190
+ server = await host.serveHttp(port, makeRouter(routes), bindHost);
185
191
  httpPort = server.port;
186
- host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
192
+ host.log("info", "urban app serving surfaces/triggers", {
193
+ port: httpPort,
194
+ routes: routes.length,
195
+ bind: bindMode,
196
+ bindHost,
197
+ });
198
+ // Binding to all interfaces exposes the app (and its token-gated capability
199
+ // hooks) on the LAN. That is the explicit opt-in this setting exists for, but a
200
+ // non-loopback bind must not serve any LOCAL-mode "well-known localhost token"
201
+ // credential (issue #235) — consumers that mint one (e.g. the agentic channel's
202
+ // LOCAL mode) must gate it on a loopback bind. Surface the exposure so it is never
203
+ // silent.
204
+ if (bindMode === "all") {
205
+ host.log("warn", "urban app bound to all interfaces — reachable off-box on the LAN", {
206
+ port: httpPort,
207
+ bindHost,
208
+ });
209
+ }
187
210
  // ADR 0057 boot handshake: under a supervising host (the nano-bpm
188
211
  // Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
189
212
  // bound on a machine-readable stdout control line so the host can