@nanobpm/urban 0.32.1 → 0.33.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.
@@ -6,10 +6,9 @@ import { type Route } from "../router.ts";
6
6
  export interface ApiBinding {
7
7
  spec: string;
8
8
  dir?: string;
9
- base?: string;
10
9
  validateResponses?: "dev" | "always" | "never";
11
10
  eject?: boolean;
12
- /** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
11
+ /** Human API docs (Swagger UI). `true`/omitted → on at `/app/api-docs` (ADR 0058); `false` →
13
12
  * off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
14
13
  * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
15
14
  docs?: boolean | string;
@@ -2,7 +2,7 @@
2
2
  // instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
3
3
  // writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
4
4
  // toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
5
- // `api.base` namespace, and for each request: parses → validates params/query/body against the spec →
5
+ // fixed `/app/api` namespace, and for each request: parses → validates params/query/body against the spec →
6
6
  // 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
7
7
  // the injected AppApi, exactly like a worker/action handler) → serializes the result.
8
8
  //
@@ -15,6 +15,10 @@ import { errorMessage } from "../guards.js";
15
15
  import { html, json, normalizeRoutePath } from "../router.js";
16
16
  import { resolveAppPath } from "./datasource.js";
17
17
  import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
18
+ /** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
19
+ * live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
20
+ * stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
21
+ const API_BASE = "/app/api";
18
22
  /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
19
23
  * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
20
24
  * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
@@ -76,7 +80,6 @@ function readApiBinding(manifest) {
76
80
  if (typeof spec !== "string" || spec.trim().length === 0)
77
81
  return undefined;
78
82
  const dir = Reflect.get(raw, "dir");
79
- const base = Reflect.get(raw, "base");
80
83
  const validateResponses = Reflect.get(raw, "validateResponses");
81
84
  const eject = Reflect.get(raw, "eject");
82
85
  const docs = Reflect.get(raw, "docs");
@@ -84,7 +87,6 @@ function readApiBinding(manifest) {
84
87
  return {
85
88
  spec: spec.trim(),
86
89
  dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
87
- base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
88
90
  validateResponses: validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
89
91
  ? validateResponses
90
92
  : undefined,
@@ -94,14 +96,15 @@ function readApiBinding(manifest) {
94
96
  docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
95
97
  };
96
98
  }
97
- /** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
98
- * serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
99
+ /** Resolve the api `base` + docs routing, in ONE place, so `mountApi` (which serves the docs) and
100
+ * `apiDocsPath` (which the pages surface links to) can never drift. The operation base is the fixed
101
+ * `/app/api` constant; only the docs route remains manifest-configurable. */
99
102
  function resolveApiRoutes(binding) {
100
- const base = normalizeRoutePath(binding.base, "/app/api");
103
+ const base = API_BASE;
101
104
  const docsEnabled = binding.docs !== false;
102
- // Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
103
- // of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
104
- // that owns the `${base}/` prefix (a string `docs` overrides the whole route).
105
+ // Swagger UI at a SIBLING of the base (`/app/api-docs`), never a subpath of it — so the docs
106
+ // routes can't collide with, or be shadowed by, the operation dispatcher that owns the
107
+ // `${base}/` prefix (a string `docs` overrides the whole route).
105
108
  const docsBase = normalizeRoutePath(typeof binding.docs === "string" ? binding.docs : `${base}-docs`, `${base}-docs`);
106
109
  return { base, docsEnabled, docsBase };
107
110
  }
@@ -143,19 +146,34 @@ export function mountApi(ctx, app) {
143
146
  const binding = readApiBinding(ctx.manifest);
144
147
  if (!binding)
145
148
  return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
146
- // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
147
- // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
148
- // like "app/api" would never match request paths, which always start with "/".
149
- //
150
- // The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
151
- // this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
152
- // shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
153
- // copy of that logic here. The spec JSON is served under the docs path so the operations
154
- // namespace stays purely operations.
149
+ // The api `base` is the fixed `/app/api` constant; only the docs routing is derived here. Both
150
+ // come from the shared helper (ADR 0058) so this module (which serves the docs) and `apiDocsPath`
151
+ // (which links to them from the pages shell) stay in lockstep — no second, drifting copy of that
152
+ // logic here. The spec JSON is served under the docs path so the operations namespace stays
153
+ // purely operations.
155
154
  const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
156
155
  const dir = binding.dir ?? "operations";
157
156
  const surfaceEject = binding.eject === true;
158
157
  const specRoutePath = `${docsBase}/openapi.json`;
158
+ // The Swagger UI page is served at `docsBase` (no trailing slash — the slash variant 308-
159
+ // redirects onto it), so its browser-relative base is the PARENT directory. Hand Swagger UI a
160
+ // DOCUMENT-relative spec URL and `servers` entry so both rebase onto the app's mount root:
161
+ // correct at the origin root (CLI :3000) AND under the Nano console reverse proxy
162
+ // (/console/app-view/<name>/), where a root-absolute "/app/…" escapes the prefix and hits the
163
+ // console origin instead of the app (issue #151).
164
+ const lastSegment = (p) => p.split("/").filter(Boolean).pop() ?? "";
165
+ const parentPath = (p) => {
166
+ const parts = p.split("/").filter(Boolean);
167
+ parts.pop();
168
+ return `/${parts.join("/")}`;
169
+ };
170
+ // `${docsBase}/openapi.json` reached relatively from the docs page = `<docs-segment>/openapi.json`.
171
+ const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
172
+ // `servers` can only be relativized when the operations `base` shares the docs page's parent
173
+ // directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
174
+ // different parent, keep the root-absolute base (works direct; try-it-out through the proxy is
175
+ // the documented limitation in #151).
176
+ const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
159
177
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
160
178
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
161
179
  // than failing app boot.
@@ -427,12 +445,13 @@ export function mountApi(ctx, app) {
427
445
  return json({ error: errorMessage(e) }, 500);
428
446
  }
429
447
  // Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
430
- // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
431
- // makes the interactive console call the real Urban routes rather than the origin root.
432
- return json({ ...d, servers: [{ url: base }] });
448
+ // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers` with a
449
+ // document-relative URL (issue #151) makes the interactive console call the real Urban routes
450
+ // both at the origin root and under the Nano console reverse proxy.
451
+ return json({ ...d, servers: [{ url: serversUrl }] });
433
452
  };
434
453
  const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
435
- const serveDocs = () => html(swaggerUiPage(docsTitle, specRoutePath));
454
+ const serveDocs = () => html(swaggerUiPage(docsTitle, specDocUrl));
436
455
  // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
437
456
  // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
438
457
  // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
@@ -14,6 +14,13 @@
14
14
  // POST /app/actions/start/<process> → engine.createInstance
15
15
  // POST /app/actions/cancel → engine.cancelInstance
16
16
  // POST /app/actions/message → engine.publishMessage
17
+ //
18
+ // Page actions are ROUTE-DRIVEN: an `actionForm`/rowAction/detail-form `action` is just
19
+ // `{ path, method?, body?, successLabel? }`. The client POSTs the resolved body (default
20
+ // `{ variables: <form> }`) to `path`, templating `{{form.KEY}}` / `{{row.KEY}}` (and the
21
+ // whole-object `{{form}}` / `{{row}}`) tokens. There are NO bespoke action kinds — a form
22
+ // reaches start/cancel/message above, an OpenAPI operation under /app/api, or any app route
23
+ // uniformly by naming its path.
17
24
  import { errorMessage, isRecord } from "../guards.js";
18
25
  import { html, json } from "../router.js";
19
26
  import { cancelInstanceReconciling } from "./cancel.js";
@@ -369,8 +376,19 @@ function rendererShell(homePage, apiDocsPath) {
369
376
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
370
377
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
371
378
  // tab a handle back to this window (reverse-tabnabbing).
379
+ //
380
+ // The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
381
+ // the shell is served at the app's mount root (always a trailing "/"), which is the origin
382
+ // root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
383
+ // proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
384
+ // and open the CONSOLE origin (e.g. :8080/app/api-docs) instead of the app's docs. Strip the
385
+ // single leading slash so it rebases onto the mount root; leave a protocol-relative "//host"
386
+ // (which apiDocsPath never emits) untouched.
387
+ const badgeHref = apiDocsPath && apiDocsPath.startsWith("/") && !apiDocsPath.startsWith("//")
388
+ ? `./${apiDocsPath.slice(1)}`
389
+ : apiDocsPath;
372
390
  const apiDocsBadge = apiDocsPath
373
- ? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
391
+ ? `\n <a class="pc-apidocs" href="${escapeAttr(badgeHref)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
374
392
  : "";
375
393
  return `<!doctype html>
376
394
  <html lang="en">
@@ -575,6 +593,59 @@ async function getJSON(url, opts) {
575
593
  return body;
576
594
  }
577
595
 
596
+ // ── route-driven actions ────────────────────────────────────────────────
597
+ // Every page action is a single primitive: POST (or any method) the resolved
598
+ // body to an app route path. There are NO bespoke action kinds — a form/row
599
+ // reaches process start, message publish, cancel, an OpenAPI operation, a
600
+ // webhook, or any app route uniformly by naming its path. Path + body may
601
+ // interpolate the current form fields and (for grid rows/detail forms) the row
602
+ // via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
603
+ // {{row}} splice the object itself (type-preserving), so a body of
604
+ // "variables": "{{form}}" sends the form object as the variable map.
605
+ function lookupToken(path, ctx) {
606
+ const segs = String(path).split(".");
607
+ const head = segs[0];
608
+ let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
609
+ for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
610
+ return base;
611
+ }
612
+ function resolveTemplate(node, ctx) {
613
+ if (typeof node === "string") {
614
+ const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
615
+ if (whole) return lookupToken(whole[1], ctx);
616
+ return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
617
+ const v = lookupToken(k, ctx);
618
+ return v == null ? "" : String(v);
619
+ });
620
+ }
621
+ if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
622
+ if (node && typeof node === "object") {
623
+ // Null-proto target so a template key like "__proto__" can't trigger prototype
624
+ // mutation (prototype-pollution class) when copied into the resolved object.
625
+ const out = Object.create(null);
626
+ for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
627
+ return out;
628
+ }
629
+ return node;
630
+ }
631
+ async function runRoute(action, ctx) {
632
+ if (!action || typeof action.path !== "string" || action.path === "") {
633
+ throw new Error("This action has no route configured (action.path is missing or blank)");
634
+ }
635
+ const path = action.path.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
636
+ const v = lookupToken(k, ctx);
637
+ return v == null ? "" : encodeURIComponent(String(v));
638
+ });
639
+ const method = String(action.method || "POST").toUpperCase();
640
+ let body;
641
+ if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
642
+ else if (ctx.form) body = { variables: ctx.form };
643
+ else body = {};
644
+ const opts = { method, headers: { "content-type": "application/json" } };
645
+ if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
646
+ return getJSON(path, opts);
647
+ }
648
+
578
649
  function el(tag, attrs = {}, ...kids) {
579
650
  const n = document.createElement(tag);
580
651
  for (const [k, v] of Object.entries(attrs)) {
@@ -675,37 +746,14 @@ function renderActionForm(node) {
675
746
  }
676
747
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
677
748
  try {
678
- // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
679
- // publishMessage -> engine.publishMessage, which correlates a message to a message start
680
- // (or intermediate catch) event. correlationKeyField names the form field that carries
681
- // the correlation key; the whole form becomes the message variables.
682
- if (p.action && p.action.kind === "publishMessage") {
683
- const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
684
- if (!correlationKey) {
685
- msg.className = "pc-msg err";
686
- msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
687
- return;
688
- }
689
- // Send the trimmed key back as the variable too, so the value a worker
690
- // reads matches the key the message was correlated on (no leading/trailing
691
- // whitespace divergence between correlationKey and variables[field]).
692
- variables[p.action.correlationKeyField] = correlationKey;
693
- await getJSON("/app/actions/message", { method: "POST",
694
- headers: { "content-type": "application/json" },
695
- body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
696
- msg.className = "pc-msg ok";
697
- msg.textContent = "Message published";
698
- } else {
699
- if (!p.action || !p.action.process) {
700
- msg.className = "pc-msg err";
701
- msg.textContent = "This form has no action configured";
702
- return;
703
- }
704
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
705
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
706
- msg.className = "pc-msg ok";
707
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
708
- }
749
+ // Route-driven: POST the form (as { variables } by default, or the action's
750
+ // own body template) to the action's route path. correlationKey / process /
751
+ // message are expressed by the path + body template the page author supplies,
752
+ // e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
753
+ const res = await runRoute(p.action, { form: variables });
754
+ msg.className = "pc-msg ok";
755
+ msg.textContent = (p.action && p.action.successLabel) ||
756
+ (res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
709
757
  for (const input of inputs.values()) input.value = "";
710
758
  document.dispatchEvent(new CustomEvent("pc:refresh"));
711
759
  } catch (e) {
@@ -780,23 +828,9 @@ function renderDataGrid(node) {
780
828
  }
781
829
 
782
830
  async function fireAction(action, row) {
783
- if (action.kind === "cancelProcess") {
784
- return getJSON("/app/actions/cancel", { method: "POST",
785
- headers: { "content-type": "application/json" },
786
- body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
787
- }
788
- if (action.kind === "publishMessage") {
789
- return getJSON("/app/actions/message", { method: "POST",
790
- headers: { "content-type": "application/json" },
791
- body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
792
- variables: { ...(action.variables || {}) } }) });
793
- }
794
- if (action.kind === "startProcess") {
795
- return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
796
- headers: { "content-type": "application/json" },
797
- body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
798
- }
799
- throw new Error("unknown action");
831
+ // Route-driven row action: POST the action's body template (default {})
832
+ // to its route path, with {{row.KEY}} tokens resolved from this row.
833
+ return runRoute(action, { row });
800
834
  }
801
835
 
802
836
  function rowActionButton(row, ra) {
@@ -831,11 +865,13 @@ function renderDataGrid(node) {
831
865
  btn.addEventListener("click", async () => {
832
866
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
833
867
  try {
834
- await getJSON("/app/actions/message", { method: "POST",
835
- headers: { "content-type": "application/json" },
836
- body: JSON.stringify({ name: f.action.message, correlationKey: row[f.action.correlationKeyField],
837
- variables: { [f.inputKey]: input.value, ...(f.action.variables || {}) } }) });
838
- msg.className = "pc-msg ok"; msg.textContent = "Sent";
868
+ // The textarea value is the form's single field (f.inputKey); route + body
869
+ // template come from f.action, e.g. { path: "/app/actions/message",
870
+ // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
871
+ // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
872
+ const form = Object.create(null); form[f.inputKey] = input.value;
873
+ await runRoute(f.action, { form, row });
874
+ msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
839
875
  document.dispatchEvent(new CustomEvent("pc:refresh"));
840
876
  } catch (e) {
841
877
  btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
@@ -95,7 +95,7 @@ export function mountSurfaces(ctx, app) {
95
95
  enabled.push(`actions(${actions.routes.length})`);
96
96
  }
97
97
  // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
98
- // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
98
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the fixed `/app/api`
99
99
  // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
100
100
  const api = mountApi(ctx, app);
101
101
  if (api.routes.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.8.0"
69
+ "create-urban-app": "^0.9.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^22.20.1",
@@ -228,12 +228,13 @@ test("a malformed spec surfaces as a 500, not a boot failure", async () => {
228
228
  assert.match(JSON.parse(res.body!).error, /failed to load/);
229
229
  });
230
230
 
231
- test("a manifest api.base without a leading slash is normalized so routes still match", async () => {
231
+ test("a leftover manifest api.base is ignored — operations always mount at the fixed /app/api", async () => {
232
232
  let got: Record<string, string> | undefined;
233
233
  const { router } = build(
234
- { spec: "openapi.json", base: "app/api" }, // no leading slash
234
+ { spec: "openapi.json", base: "/somewhere/else" }, // base is no longer honoured
235
235
  { "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
236
236
  );
237
+ assert.equal((await router(req("GET", "/somewhere/else/invoices/42"))).status, 404);
237
238
  const res = await router(req("GET", "/app/api/invoices/42"));
238
239
  assert.equal(res.status, 200);
239
240
  assert.deepEqual(got, { id: "42" });
@@ -393,10 +394,10 @@ test("an op declaring no requestBody gets body:undefined at runtime (matches der
393
394
  assert.equal(seenBody, undefined);
394
395
  });
395
396
 
396
- test("readApiBinding trims whitespace on base/dir/spec so benign formatting can't break resolution", async () => {
397
+ test("readApiBinding trims whitespace on dir/spec so benign formatting can't break resolution", async () => {
397
398
  let got: Record<string, string> | undefined;
398
399
  const { router, imported } = build(
399
- { spec: " openapi.json ", base: " app/api ", dir: " operations " },
400
+ { spec: " openapi.json ", dir: " operations " },
400
401
  { "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
401
402
  );
402
403
  const res = await router(req("GET", "/app/api/invoices/42"));
@@ -458,9 +459,10 @@ test("serves the OpenAPI spec at {base}-docs/openapi.json, rebased to the mounte
458
459
  const doc = JSON.parse(res.body ?? "{}");
459
460
  // The doc is served intact...
460
461
  assert.ok(doc.paths["/invoices"], "keeps the operation paths");
461
- // ...but `servers` is rebased to `base` so Swagger UI "Try it out" hits the real routes
462
+ // ...but `servers` is rebased to `base` (document-relative, issue #151) so Swagger UI "Try it
463
+ // out" hits the real routes both at the origin root and under the Nano console reverse proxy
462
464
  // (operations mount under /app/api, not the spec's bare /invoices).
463
- assert.deepEqual(doc.servers, [{ url: "/app/api" }]);
465
+ assert.deepEqual(doc.servers, [{ url: "api" }]);
464
466
  });
465
467
 
466
468
  test("serves the Swagger UI page at {base}-docs by default", async () => {
@@ -470,8 +472,10 @@ test("serves the Swagger UI page at {base}-docs by default", async () => {
470
472
  assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
471
473
  const body = res.body ?? "";
472
474
  assert.match(body, /swagger-ui/);
473
- // The UI reads the spec route we serve.
474
- assert.match(body, /\/app\/api-docs\/openapi\.json/);
475
+ // The UI reads the spec via a DOCUMENT-relative URL (issue #151) so it resolves through the
476
+ // Nano console reverse proxy, not a root-absolute path that escapes to the console origin.
477
+ assert.match(body, /url: "api-docs\/openapi\.json"/);
478
+ assert.doesNotMatch(body, /url: "\/app\/api-docs\/openapi\.json"/);
475
479
  });
476
480
 
477
481
  test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
@@ -512,7 +516,7 @@ test("a string docs overrides the UI route (and the spec route beneath it)", asy
512
516
 
513
517
  test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
514
518
  assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
515
- assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/x-docs");
519
+ assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/app/api-docs"); // base ignored
516
520
  assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
517
521
  assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
518
522
  assert.equal(apiDocsPath({}), undefined);
@@ -2,7 +2,7 @@
2
2
  // instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
3
3
  // writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
4
4
  // toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
5
- // `api.base` namespace, and for each request: parses → validates params/query/body against the spec →
5
+ // fixed `/app/api` namespace, and for each request: parses → validates params/query/body against the spec →
6
6
  // 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
7
7
  // the injected AppApi, exactly like a worker/action handler) → serializes the result.
8
8
  //
@@ -38,15 +38,19 @@ import {
38
38
  export interface ApiBinding {
39
39
  spec: string;
40
40
  dir?: string;
41
- base?: string;
42
41
  validateResponses?: "dev" | "always" | "never";
43
42
  eject?: boolean;
44
- /** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
43
+ /** Human API docs (Swagger UI). `true`/omitted → on at `/app/api-docs` (ADR 0058); `false` →
45
44
  * off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
46
45
  * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
47
46
  docs?: boolean | string;
48
47
  }
49
48
 
49
+ /** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
50
+ * live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
51
+ * stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
52
+ const API_BASE = "/app/api";
53
+
50
54
  /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
51
55
  * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
52
56
  * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
@@ -152,7 +156,6 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
152
156
  const spec = Reflect.get(raw, "spec");
153
157
  if (typeof spec !== "string" || spec.trim().length === 0) return undefined;
154
158
  const dir = Reflect.get(raw, "dir");
155
- const base = Reflect.get(raw, "base");
156
159
  const validateResponses = Reflect.get(raw, "validateResponses");
157
160
  const eject = Reflect.get(raw, "eject");
158
161
  const docs = Reflect.get(raw, "docs");
@@ -160,7 +163,6 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
160
163
  return {
161
164
  spec: spec.trim(),
162
165
  dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
163
- base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
164
166
  validateResponses:
165
167
  validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
166
168
  ? validateResponses
@@ -172,18 +174,19 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
172
174
  };
173
175
  }
174
176
 
175
- /** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
176
- * serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
177
+ /** Resolve the api `base` + docs routing, in ONE place, so `mountApi` (which serves the docs) and
178
+ * `apiDocsPath` (which the pages surface links to) can never drift. The operation base is the fixed
179
+ * `/app/api` constant; only the docs route remains manifest-configurable. */
177
180
  function resolveApiRoutes(binding: ApiBinding): {
178
181
  base: string;
179
182
  docsEnabled: boolean;
180
183
  docsBase: string;
181
184
  } {
182
- const base = normalizeRoutePath(binding.base, "/app/api");
185
+ const base = API_BASE;
183
186
  const docsEnabled = binding.docs !== false;
184
- // Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
185
- // of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
186
- // that owns the `${base}/` prefix (a string `docs` overrides the whole route).
187
+ // Swagger UI at a SIBLING of the base (`/app/api-docs`), never a subpath of it — so the docs
188
+ // routes can't collide with, or be shadowed by, the operation dispatcher that owns the
189
+ // `${base}/` prefix (a string `docs` overrides the whole route).
187
190
  const docsBase = normalizeRoutePath(
188
191
  typeof binding.docs === "string" ? binding.docs : `${base}-docs`,
189
192
  `${base}-docs`,
@@ -239,19 +242,34 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
239
242
  const binding = readApiBinding(ctx.manifest);
240
243
  if (!binding) return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
241
244
 
242
- // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
243
- // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
244
- // like "app/api" would never match request paths, which always start with "/".
245
- //
246
- // The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
247
- // this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
248
- // shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
249
- // copy of that logic here. The spec JSON is served under the docs path so the operations
250
- // namespace stays purely operations.
245
+ // The api `base` is the fixed `/app/api` constant; only the docs routing is derived here. Both
246
+ // come from the shared helper (ADR 0058) so this module (which serves the docs) and `apiDocsPath`
247
+ // (which links to them from the pages shell) stay in lockstep — no second, drifting copy of that
248
+ // logic here. The spec JSON is served under the docs path so the operations namespace stays
249
+ // purely operations.
251
250
  const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
252
251
  const dir = binding.dir ?? "operations";
253
252
  const surfaceEject = binding.eject === true;
254
253
  const specRoutePath = `${docsBase}/openapi.json`;
254
+ // The Swagger UI page is served at `docsBase` (no trailing slash — the slash variant 308-
255
+ // redirects onto it), so its browser-relative base is the PARENT directory. Hand Swagger UI a
256
+ // DOCUMENT-relative spec URL and `servers` entry so both rebase onto the app's mount root:
257
+ // correct at the origin root (CLI :3000) AND under the Nano console reverse proxy
258
+ // (/console/app-view/<name>/), where a root-absolute "/app/…" escapes the prefix and hits the
259
+ // console origin instead of the app (issue #151).
260
+ const lastSegment = (p: string): string => p.split("/").filter(Boolean).pop() ?? "";
261
+ const parentPath = (p: string): string => {
262
+ const parts = p.split("/").filter(Boolean);
263
+ parts.pop();
264
+ return `/${parts.join("/")}`;
265
+ };
266
+ // `${docsBase}/openapi.json` reached relatively from the docs page = `<docs-segment>/openapi.json`.
267
+ const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
268
+ // `servers` can only be relativized when the operations `base` shares the docs page's parent
269
+ // directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
270
+ // different parent, keep the root-absolute base (works direct; try-it-out through the proxy is
271
+ // the documented limitation in #151).
272
+ const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
255
273
 
256
274
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
257
275
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
@@ -538,12 +556,13 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
538
556
  return json({ error: errorMessage(e) }, 500);
539
557
  }
540
558
  // Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
541
- // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
542
- // makes the interactive console call the real Urban routes rather than the origin root.
543
- return json({ ...d, servers: [{ url: base }] });
559
+ // (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers` with a
560
+ // document-relative URL (issue #151) makes the interactive console call the real Urban routes
561
+ // both at the origin root and under the Nano console reverse proxy.
562
+ return json({ ...d, servers: [{ url: serversUrl }] });
544
563
  };
545
564
  const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
546
- const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specRoutePath));
565
+ const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl));
547
566
  // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
548
567
  // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
549
568
  // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
@@ -98,6 +98,29 @@ test("GET / serves the renderer shell with the home marker", async () => {
98
98
  assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\.js"/);
99
99
  });
100
100
 
101
+ test("the API docs badge links via a document-relative href (proxy-safe)", async () => {
102
+ // With an `api` binding, the shell renders a persistent "API docs" badge. Its
103
+ // href must be DOCUMENT-relative (./app/api-docs), mirroring the runtime.js
104
+ // script tag, so it resolves against the shell's mount root. A root-absolute
105
+ // "/app/api-docs" would escape the Nano console's /console/app-view/<name>/
106
+ // reverse proxy and open the console origin (e.g. :8080/app/api-docs) instead
107
+ // of the app's own docs — the port-mismatch bug this guards against.
108
+ const { engine } = fakeEngine();
109
+ const routes = createPagesRoutes(
110
+ { pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
111
+ {
112
+ db: fakeDb(),
113
+ engine,
114
+ readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
115
+ listPages: async () => ["home"],
116
+ },
117
+ );
118
+ const res = await makeRouter(routes)(req("GET", "/"));
119
+ assert.match(res.body ?? "", /class="pc-apidocs"/);
120
+ assert.match(res.body ?? "", /href="\.\/app\/api-docs"/);
121
+ assert.doesNotMatch(res.body ?? "", /href="\/app\/api-docs"/);
122
+ });
123
+
101
124
  test("the shell styles the app through the shared --nano-* token contract", async () => {
102
125
  const res = await dispatch("GET", "/");
103
126
  const html = res.body ?? "";
@@ -129,7 +152,7 @@ test("the shell renders an API-docs badge only when an apiDocsPath is provided",
129
152
  );
130
153
  const withBadge = (await makeRouter(routes)(req("GET", "/"))).body ?? "";
131
154
  assert.match(withBadge, /class="pc-apidocs"/);
132
- assert.match(withBadge, /href="\/app\/api-docs"/);
155
+ assert.match(withBadge, /href="\.\/app\/api-docs"/);
133
156
  // Opens in a new tab with a hardened rel (no reverse-tabnabbing handle).
134
157
  assert.match(withBadge, /rel="noopener noreferrer"/);
135
158
 
@@ -138,22 +161,23 @@ test("the shell renders an API-docs badge only when an apiDocsPath is provided",
138
161
  assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
139
162
  });
140
163
 
141
- test("renderActionForm supports a publishMessage action (message-started processes)", async () => {
164
+ test("page actions are route-driven: the runtime ships a single callRoute dispatcher, no bespoke kinds", async () => {
142
165
  const res = await dispatch("GET", "/app/runtime.js");
143
166
  const js = res.body ?? "";
144
- // The form submit branches on the action kind: publishMessage posts to the message endpoint,
145
- // deriving the correlation key from the named form field; startProcess keeps its old path.
146
- assert.match(js, /p\.action\.kind === "publishMessage"/);
147
- assert.match(js, /variables\[p\.action\.correlationKeyField\]/);
148
- assert.match(js, /"\/app\/actions\/message"/);
149
- // The trimmed correlation key is written back as the variable, so the value a
150
- // worker reads matches the key the message was correlated on.
151
- assert.match(js, /variables\[p\.action\.correlationKeyField\] = correlationKey/);
152
- // startProcess remains the default/back-compat branch.
153
- assert.match(js, /"\/app\/actions\/start\/" \+ encodeURIComponent\(p\.action\.process\)/);
154
- // A missing action is reported clearly instead of throwing on p.action.process.
155
- assert.match(js, /if \(!p\.action \|\| !p\.action\.process\)/);
156
- assert.match(js, /no action configured/);
167
+ // A single primitive resolves { path, method?, body? } and POSTs it — there is no per-kind
168
+ // branching. The template resolver splices {{form}} / {{row}} and {{form.KEY}} / {{row.KEY}}.
169
+ assert.match(js, /async function runRoute\(action, ctx\)/);
170
+ assert.match(js, /function resolveTemplate\(node, ctx\)/);
171
+ assert.match(js, /function lookupToken\(path, ctx\)/);
172
+ // The form default body is the { variables } envelope, so plain forms hit start operations
173
+ // without any per-form config; row/detail forms carry an explicit body template instead.
174
+ assert.match(js, /body = \{ variables: ctx\.form \}/);
175
+ // Path tokens are URL-encoded (real keys like owner/repo#123 must not break the path).
176
+ assert.match(js, /encodeURIComponent\(String\(v\)\)/);
177
+ // The removed bespoke action kinds no longer appear anywhere in the client runtime.
178
+ assert.doesNotMatch(js, /p\.action\.kind/);
179
+ assert.doesNotMatch(js, /correlationKeyField/);
180
+ assert.doesNotMatch(js, /action\.kind === "(startProcess|publishMessage|cancelProcess)"/);
157
181
  });
158
182
 
159
183
  test("the renderer bridges the console theme over postMessage", async () => {
@@ -14,6 +14,13 @@
14
14
  // POST /app/actions/start/<process> → engine.createInstance
15
15
  // POST /app/actions/cancel → engine.cancelInstance
16
16
  // POST /app/actions/message → engine.publishMessage
17
+ //
18
+ // Page actions are ROUTE-DRIVEN: an `actionForm`/rowAction/detail-form `action` is just
19
+ // `{ path, method?, body?, successLabel? }`. The client POSTs the resolved body (default
20
+ // `{ variables: <form> }`) to `path`, templating `{{form.KEY}}` / `{{row.KEY}}` (and the
21
+ // whole-object `{{form}}` / `{{row}}`) tokens. There are NO bespoke action kinds — a form
22
+ // reaches start/cancel/message above, an OpenAPI operation under /app/api, or any app route
23
+ // uniformly by naming its path.
17
24
 
18
25
  import type { AppApi, RuntimeContext } from "../context.ts";
19
26
  import { errorMessage, isRecord } from "../guards.ts";
@@ -414,8 +421,20 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
414
421
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
415
422
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
416
423
  // tab a handle back to this window (reverse-tabnabbing).
424
+ //
425
+ // The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
426
+ // the shell is served at the app's mount root (always a trailing "/"), which is the origin
427
+ // root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
428
+ // proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
429
+ // and open the CONSOLE origin (e.g. :8080/app/api-docs) instead of the app's docs. Strip the
430
+ // single leading slash so it rebases onto the mount root; leave a protocol-relative "//host"
431
+ // (which apiDocsPath never emits) untouched.
432
+ const badgeHref =
433
+ apiDocsPath && apiDocsPath.startsWith("/") && !apiDocsPath.startsWith("//")
434
+ ? `./${apiDocsPath.slice(1)}`
435
+ : apiDocsPath;
417
436
  const apiDocsBadge = apiDocsPath
418
- ? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
437
+ ? `\n <a class="pc-apidocs" href="${escapeAttr(badgeHref!)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
419
438
  : "";
420
439
  return `<!doctype html>
421
440
  <html lang="en">
@@ -622,6 +641,59 @@ async function getJSON(url, opts) {
622
641
  return body;
623
642
  }
624
643
 
644
+ // ── route-driven actions ────────────────────────────────────────────────
645
+ // Every page action is a single primitive: POST (or any method) the resolved
646
+ // body to an app route path. There are NO bespoke action kinds — a form/row
647
+ // reaches process start, message publish, cancel, an OpenAPI operation, a
648
+ // webhook, or any app route uniformly by naming its path. Path + body may
649
+ // interpolate the current form fields and (for grid rows/detail forms) the row
650
+ // via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
651
+ // {{row}} splice the object itself (type-preserving), so a body of
652
+ // "variables": "{{form}}" sends the form object as the variable map.
653
+ function lookupToken(path, ctx) {
654
+ const segs = String(path).split(".");
655
+ const head = segs[0];
656
+ let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
657
+ for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
658
+ return base;
659
+ }
660
+ function resolveTemplate(node, ctx) {
661
+ if (typeof node === "string") {
662
+ const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
663
+ if (whole) return lookupToken(whole[1], ctx);
664
+ return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
665
+ const v = lookupToken(k, ctx);
666
+ return v == null ? "" : String(v);
667
+ });
668
+ }
669
+ if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
670
+ if (node && typeof node === "object") {
671
+ // Null-proto target so a template key like "__proto__" can't trigger prototype
672
+ // mutation (prototype-pollution class) when copied into the resolved object.
673
+ const out = Object.create(null);
674
+ for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
675
+ return out;
676
+ }
677
+ return node;
678
+ }
679
+ async function runRoute(action, ctx) {
680
+ if (!action || typeof action.path !== "string" || action.path === "") {
681
+ throw new Error("This action has no route configured (action.path is missing or blank)");
682
+ }
683
+ const path = action.path.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
684
+ const v = lookupToken(k, ctx);
685
+ return v == null ? "" : encodeURIComponent(String(v));
686
+ });
687
+ const method = String(action.method || "POST").toUpperCase();
688
+ let body;
689
+ if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
690
+ else if (ctx.form) body = { variables: ctx.form };
691
+ else body = {};
692
+ const opts = { method, headers: { "content-type": "application/json" } };
693
+ if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
694
+ return getJSON(path, opts);
695
+ }
696
+
625
697
  function el(tag, attrs = {}, ...kids) {
626
698
  const n = document.createElement(tag);
627
699
  for (const [k, v] of Object.entries(attrs)) {
@@ -722,37 +794,14 @@ function renderActionForm(node) {
722
794
  }
723
795
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
724
796
  try {
725
- // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
726
- // publishMessage -> engine.publishMessage, which correlates a message to a message start
727
- // (or intermediate catch) event. correlationKeyField names the form field that carries
728
- // the correlation key; the whole form becomes the message variables.
729
- if (p.action && p.action.kind === "publishMessage") {
730
- const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
731
- if (!correlationKey) {
732
- msg.className = "pc-msg err";
733
- msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
734
- return;
735
- }
736
- // Send the trimmed key back as the variable too, so the value a worker
737
- // reads matches the key the message was correlated on (no leading/trailing
738
- // whitespace divergence between correlationKey and variables[field]).
739
- variables[p.action.correlationKeyField] = correlationKey;
740
- await getJSON("/app/actions/message", { method: "POST",
741
- headers: { "content-type": "application/json" },
742
- body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
743
- msg.className = "pc-msg ok";
744
- msg.textContent = "Message published";
745
- } else {
746
- if (!p.action || !p.action.process) {
747
- msg.className = "pc-msg err";
748
- msg.textContent = "This form has no action configured";
749
- return;
750
- }
751
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
752
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
753
- msg.className = "pc-msg ok";
754
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
755
- }
797
+ // Route-driven: POST the form (as { variables } by default, or the action's
798
+ // own body template) to the action's route path. correlationKey / process /
799
+ // message are expressed by the path + body template the page author supplies,
800
+ // e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
801
+ const res = await runRoute(p.action, { form: variables });
802
+ msg.className = "pc-msg ok";
803
+ msg.textContent = (p.action && p.action.successLabel) ||
804
+ (res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
756
805
  for (const input of inputs.values()) input.value = "";
757
806
  document.dispatchEvent(new CustomEvent("pc:refresh"));
758
807
  } catch (e) {
@@ -827,23 +876,9 @@ function renderDataGrid(node) {
827
876
  }
828
877
 
829
878
  async function fireAction(action, row) {
830
- if (action.kind === "cancelProcess") {
831
- return getJSON("/app/actions/cancel", { method: "POST",
832
- headers: { "content-type": "application/json" },
833
- body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
834
- }
835
- if (action.kind === "publishMessage") {
836
- return getJSON("/app/actions/message", { method: "POST",
837
- headers: { "content-type": "application/json" },
838
- body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
839
- variables: { ...(action.variables || {}) } }) });
840
- }
841
- if (action.kind === "startProcess") {
842
- return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
843
- headers: { "content-type": "application/json" },
844
- body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
845
- }
846
- throw new Error("unknown action");
879
+ // Route-driven row action: POST the action's body template (default {})
880
+ // to its route path, with {{row.KEY}} tokens resolved from this row.
881
+ return runRoute(action, { row });
847
882
  }
848
883
 
849
884
  function rowActionButton(row, ra) {
@@ -878,11 +913,13 @@ function renderDataGrid(node) {
878
913
  btn.addEventListener("click", async () => {
879
914
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
880
915
  try {
881
- await getJSON("/app/actions/message", { method: "POST",
882
- headers: { "content-type": "application/json" },
883
- body: JSON.stringify({ name: f.action.message, correlationKey: row[f.action.correlationKeyField],
884
- variables: { [f.inputKey]: input.value, ...(f.action.variables || {}) } }) });
885
- msg.className = "pc-msg ok"; msg.textContent = "Sent";
916
+ // The textarea value is the form's single field (f.inputKey); route + body
917
+ // template come from f.action, e.g. { path: "/app/actions/message",
918
+ // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
919
+ // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
920
+ const form = Object.create(null); form[f.inputKey] = input.value;
921
+ await runRoute(f.action, { form, row });
922
+ msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
886
923
  document.dispatchEvent(new CustomEvent("pc:refresh"));
887
924
  } catch (e) {
888
925
  btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
@@ -111,7 +111,7 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
111
111
  }
112
112
 
113
113
  // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
114
- // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
114
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the fixed `/app/api`
115
115
  // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
116
116
  const api = mountApi(ctx, app);
117
117
  if (api.routes.length > 0) {