@nanobpm/urban 0.32.2 → 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,15 +146,11 @@ 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;
@@ -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";
@@ -586,6 +593,59 @@ async function getJSON(url, opts) {
586
593
  return body;
587
594
  }
588
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
+
589
649
  function el(tag, attrs = {}, ...kids) {
590
650
  const n = document.createElement(tag);
591
651
  for (const [k, v] of Object.entries(attrs)) {
@@ -686,37 +746,14 @@ function renderActionForm(node) {
686
746
  }
687
747
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
688
748
  try {
689
- // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
690
- // publishMessage -> engine.publishMessage, which correlates a message to a message start
691
- // (or intermediate catch) event. correlationKeyField names the form field that carries
692
- // the correlation key; the whole form becomes the message variables.
693
- if (p.action && p.action.kind === "publishMessage") {
694
- const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
695
- if (!correlationKey) {
696
- msg.className = "pc-msg err";
697
- msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
698
- return;
699
- }
700
- // Send the trimmed key back as the variable too, so the value a worker
701
- // reads matches the key the message was correlated on (no leading/trailing
702
- // whitespace divergence between correlationKey and variables[field]).
703
- variables[p.action.correlationKeyField] = correlationKey;
704
- await getJSON("/app/actions/message", { method: "POST",
705
- headers: { "content-type": "application/json" },
706
- body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
707
- msg.className = "pc-msg ok";
708
- msg.textContent = "Message published";
709
- } else {
710
- if (!p.action || !p.action.process) {
711
- msg.className = "pc-msg err";
712
- msg.textContent = "This form has no action configured";
713
- return;
714
- }
715
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
716
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
717
- msg.className = "pc-msg ok";
718
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
719
- }
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");
720
757
  for (const input of inputs.values()) input.value = "";
721
758
  document.dispatchEvent(new CustomEvent("pc:refresh"));
722
759
  } catch (e) {
@@ -791,23 +828,9 @@ function renderDataGrid(node) {
791
828
  }
792
829
 
793
830
  async function fireAction(action, row) {
794
- if (action.kind === "cancelProcess") {
795
- return getJSON("/app/actions/cancel", { method: "POST",
796
- headers: { "content-type": "application/json" },
797
- body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
798
- }
799
- if (action.kind === "publishMessage") {
800
- return getJSON("/app/actions/message", { method: "POST",
801
- headers: { "content-type": "application/json" },
802
- body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
803
- variables: { ...(action.variables || {}) } }) });
804
- }
805
- if (action.kind === "startProcess") {
806
- return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
807
- headers: { "content-type": "application/json" },
808
- body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
809
- }
810
- 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 });
811
834
  }
812
835
 
813
836
  function rowActionButton(row, ra) {
@@ -842,11 +865,13 @@ function renderDataGrid(node) {
842
865
  btn.addEventListener("click", async () => {
843
866
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
844
867
  try {
845
- await getJSON("/app/actions/message", { method: "POST",
846
- headers: { "content-type": "application/json" },
847
- body: JSON.stringify({ name: f.action.message, correlationKey: row[f.action.correlationKeyField],
848
- variables: { [f.inputKey]: input.value, ...(f.action.variables || {}) } }) });
849
- 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";
850
875
  document.dispatchEvent(new CustomEvent("pc:refresh"));
851
876
  } catch (e) {
852
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.2",
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"));
@@ -515,7 +516,7 @@ test("a string docs overrides the UI route (and the spec route beneath it)", asy
515
516
 
516
517
  test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
517
518
  assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
518
- 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
519
520
  assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
520
521
  assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
521
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,15 +242,11 @@ 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;
@@ -161,22 +161,23 @@ test("the shell renders an API-docs badge only when an apiDocsPath is provided",
161
161
  assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
162
162
  });
163
163
 
164
- 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 () => {
165
165
  const res = await dispatch("GET", "/app/runtime.js");
166
166
  const js = res.body ?? "";
167
- // The form submit branches on the action kind: publishMessage posts to the message endpoint,
168
- // deriving the correlation key from the named form field; startProcess keeps its old path.
169
- assert.match(js, /p\.action\.kind === "publishMessage"/);
170
- assert.match(js, /variables\[p\.action\.correlationKeyField\]/);
171
- assert.match(js, /"\/app\/actions\/message"/);
172
- // The trimmed correlation key is written back as the variable, so the value a
173
- // worker reads matches the key the message was correlated on.
174
- assert.match(js, /variables\[p\.action\.correlationKeyField\] = correlationKey/);
175
- // startProcess remains the default/back-compat branch.
176
- assert.match(js, /"\/app\/actions\/start\/" \+ encodeURIComponent\(p\.action\.process\)/);
177
- // A missing action is reported clearly instead of throwing on p.action.process.
178
- assert.match(js, /if \(!p\.action \|\| !p\.action\.process\)/);
179
- 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)"/);
180
181
  });
181
182
 
182
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";
@@ -634,6 +641,59 @@ async function getJSON(url, opts) {
634
641
  return body;
635
642
  }
636
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
+
637
697
  function el(tag, attrs = {}, ...kids) {
638
698
  const n = document.createElement(tag);
639
699
  for (const [k, v] of Object.entries(attrs)) {
@@ -734,37 +794,14 @@ function renderActionForm(node) {
734
794
  }
735
795
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
736
796
  try {
737
- // Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
738
- // publishMessage -> engine.publishMessage, which correlates a message to a message start
739
- // (or intermediate catch) event. correlationKeyField names the form field that carries
740
- // the correlation key; the whole form becomes the message variables.
741
- if (p.action && p.action.kind === "publishMessage") {
742
- const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
743
- if (!correlationKey) {
744
- msg.className = "pc-msg err";
745
- msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
746
- return;
747
- }
748
- // Send the trimmed key back as the variable too, so the value a worker
749
- // reads matches the key the message was correlated on (no leading/trailing
750
- // whitespace divergence between correlationKey and variables[field]).
751
- variables[p.action.correlationKeyField] = correlationKey;
752
- await getJSON("/app/actions/message", { method: "POST",
753
- headers: { "content-type": "application/json" },
754
- body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
755
- msg.className = "pc-msg ok";
756
- msg.textContent = "Message published";
757
- } else {
758
- if (!p.action || !p.action.process) {
759
- msg.className = "pc-msg err";
760
- msg.textContent = "This form has no action configured";
761
- return;
762
- }
763
- const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
764
- { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
765
- msg.className = "pc-msg ok";
766
- msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
767
- }
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");
768
805
  for (const input of inputs.values()) input.value = "";
769
806
  document.dispatchEvent(new CustomEvent("pc:refresh"));
770
807
  } catch (e) {
@@ -839,23 +876,9 @@ function renderDataGrid(node) {
839
876
  }
840
877
 
841
878
  async function fireAction(action, row) {
842
- if (action.kind === "cancelProcess") {
843
- return getJSON("/app/actions/cancel", { method: "POST",
844
- headers: { "content-type": "application/json" },
845
- body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
846
- }
847
- if (action.kind === "publishMessage") {
848
- return getJSON("/app/actions/message", { method: "POST",
849
- headers: { "content-type": "application/json" },
850
- body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
851
- variables: { ...(action.variables || {}) } }) });
852
- }
853
- if (action.kind === "startProcess") {
854
- return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
855
- headers: { "content-type": "application/json" },
856
- body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
857
- }
858
- 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 });
859
882
  }
860
883
 
861
884
  function rowActionButton(row, ra) {
@@ -890,11 +913,13 @@ function renderDataGrid(node) {
890
913
  btn.addEventListener("click", async () => {
891
914
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
892
915
  try {
893
- await getJSON("/app/actions/message", { method: "POST",
894
- headers: { "content-type": "application/json" },
895
- body: JSON.stringify({ name: f.action.message, correlationKey: row[f.action.correlationKeyField],
896
- variables: { [f.inputKey]: input.value, ...(f.action.variables || {}) } }) });
897
- 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";
898
923
  document.dispatchEvent(new CustomEvent("pc:refresh"));
899
924
  } catch (e) {
900
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) {