@nanobpm/urban 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/runtime/core/modules/canonical-projections.d.ts +13 -0
  2. package/dist/runtime/core/modules/canonical-projections.js +30 -0
  3. package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
  4. package/dist/runtime/core/modules/formjs.gen.js +5 -0
  5. package/dist/runtime/core/modules/forms.d.ts +22 -0
  6. package/dist/runtime/core/modules/forms.js +59 -0
  7. package/dist/runtime/core/modules/instance-state-store.d.ts +50 -0
  8. package/dist/runtime/core/modules/instance-state-store.js +101 -0
  9. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
  10. package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
  11. package/dist/runtime/core/modules/pages.js +36 -0
  12. package/dist/runtime/core/modules/runtime.gen.js +1 -1
  13. package/dist/runtime/core/modules/surfaces.js +18 -124
  14. package/dist/runtime/core/modules/workers.js +33 -0
  15. package/dist/runtime/index.d.ts +5 -0
  16. package/dist/runtime/index.js +8 -1
  17. package/package.json +1 -1
  18. package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
  19. package/src/runtime/browser/formjs.browser.js +192 -0
  20. package/src/runtime/browser/formjs.browser.test.ts +136 -0
  21. package/src/runtime/browser/runtime.browser.js +101 -1
  22. package/src/runtime/browser/runtime.engineform.test.ts +158 -0
  23. package/src/runtime/core/modules/canonical-projections.test.ts +190 -0
  24. package/src/runtime/core/modules/canonical-projections.ts +38 -0
  25. package/src/runtime/core/modules/formjs.gen.ts +6 -0
  26. package/src/runtime/core/modules/forms.test.ts +122 -0
  27. package/src/runtime/core/modules/forms.ts +67 -0
  28. package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
  29. package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
  30. package/src/runtime/core/modules/instance-state-store.ts +135 -0
  31. package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
  32. package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
  33. package/src/runtime/core/modules/pages.test.ts +99 -0
  34. package/src/runtime/core/modules/pages.ts +38 -0
  35. package/src/runtime/core/modules/runtime.gen.ts +1 -1
  36. package/src/runtime/core/modules/surfaces.ts +19 -121
  37. package/src/runtime/core/modules/workers.ts +33 -0
  38. package/src/runtime/index.ts +29 -1
@@ -6,8 +6,9 @@
6
6
  // base/api/complete`). A task with no linked form keeps the bare key-list + raw-complete
7
7
  // behavior. The `chat` surface is stubbed as a mount point (LLM wiring is a separate concern).
8
8
  // Surfaces contribute routes to the shared server.
9
- import { presentFormIdentifier } from "../host.js";
10
- import { html, json, noContent, normalizeRoutePath } from "../router.js";
9
+ import { html, json, normalizeRoutePath } from "../router.js";
10
+ import { FORMJS_JS } from "./formjs.gen.js";
11
+ import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
11
12
  import { mountActions } from "./actions.js";
12
13
  import { mountAgent } from "./agent.js";
13
14
  import { mountApi } from "./api.js";
@@ -30,13 +31,15 @@ function inboxPage() {
30
31
  ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
31
32
  code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
32
33
  button{font:inherit;padding:.2rem .6rem;cursor:pointer}
33
- form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
34
- form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
35
- form .field input[type=checkbox],form .field input[type=radio]{width:auto}
34
+ .njf-field label{display:block;margin:.6rem 0 .2rem;font-weight:600}
35
+ .njf-field input,.njf-field textarea,.njf-field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
36
+ .njf-field input[type=checkbox],.njf-field input[type=radio]{width:auto}
37
+ .njf-actions{margin-top:.6rem}
36
38
  #form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
37
39
  .muted{color:#666}</style>
38
40
  <h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
39
41
  <div id="form"></div>
42
+ <script>${FORMJS_JS}</script>
40
43
  <script>
41
44
  const BASE=location.pathname.replace(/[/]+$/,'');
42
45
  const tasksEl=document.getElementById('tasks');
@@ -71,7 +74,14 @@ function openForm(t){
71
74
  api('/api/form?'+q).then(f=>{
72
75
  formEl.replaceChildren();
73
76
  if(!f||!f.schema){renderNoForm(t);return;}
74
- renderForm(t,f.schema);
77
+ // Reuse the ONE shared form-js renderer (NanoFormJs) — no fork with the pages surface.
78
+ formEl.appendChild(NanoFormJs.renderForm(f.schema,{
79
+ heading:'Task '+(t.elementId||t.userTaskKey),
80
+ submitLabel:'Submit',
81
+ cancelLabel:'Cancel',
82
+ onSubmit:(variables)=>complete(t.userTaskKey,variables),
83
+ onCancel:loadTasks,
84
+ }));
75
85
  }).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
76
86
  }
77
87
 
@@ -83,93 +93,6 @@ function renderNoForm(t){
83
93
  formEl.appendChild(bar);
84
94
  }
85
95
 
86
- function renderForm(t,schema){
87
- const form=document.createElement('form');
88
- form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
89
- const components=Array.isArray(schema.components)?schema.components:[];
90
- const inputs=[];
91
- for(const c of components){
92
- const built=buildField(c);
93
- if(!built)continue;
94
- form.appendChild(built.field);
95
- if(built.read)inputs.push(built.read);
96
- }
97
- const submit=btn('Submit',null);submit.type='submit';
98
- const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
99
- form.appendChild(bar);
100
- form.addEventListener('submit',e=>{
101
- e.preventDefault();
102
- // Null-prototype: field keys come from the engine-supplied form schema. A component
103
- // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
104
- // through JSON), never mutate a prototype — no prototype pollution in the page runtime.
105
- const variables=Object.create(null);
106
- for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
107
- submit.disabled=true;
108
- complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
109
- });
110
- formEl.appendChild(form);
111
- }
112
-
113
- function buildField(c){
114
- const type=c&&c.type;
115
- // Static (keyless) components: show text as a paragraph, ignore layout-only ones.
116
- if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
117
- const key=c&&c.key;
118
- if(!key)return null;
119
- const wrap=el('div',{class:'field'});
120
- const label=typeof c.label==='string'&&c.label?c.label:key;
121
- if(type==='checkbox'){
122
- const input=document.createElement('input');input.type='checkbox';
123
- const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
124
- wrap.appendChild(lab);
125
- return {field:wrap,read:()=>({key,value:input.checked})};
126
- }
127
- const labelEl=el('label',{},label);
128
- wrap.appendChild(labelEl);
129
- let input;
130
- if(type==='textarea'){input=document.createElement('textarea');}
131
- else if(type==='select'){
132
- input=document.createElement('select');
133
- input.appendChild(el('option',{value:''},'—'));
134
- for(const o of (Array.isArray(c.values)?c.values:[])){
135
- input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
136
- }
137
- }
138
- else if(type==='radio'){
139
- const name='r'+Math.random().toString(36).slice(2);
140
- const group=el('div',{});
141
- for(const o of (Array.isArray(c.values)?c.values:[])){
142
- const rlab=el('label',{});
143
- const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
144
- rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
145
- group.appendChild(rlab);
146
- }
147
- wrap.appendChild(group);
148
- return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
149
- }
150
- else{
151
- input=document.createElement('input');
152
- input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
153
- }
154
- // Associate the label with the input (for/id) so screen readers announce it on focus.
155
- const fieldId='f'+Math.random().toString(36).slice(2);
156
- input.id=fieldId;labelEl.setAttribute('for',fieldId);
157
- wrap.appendChild(input);
158
- const isNumber=type==='number';
159
- return {field:wrap,read:()=>{
160
- const raw=input.value;
161
- if(raw===''||raw==null)return null;
162
- if(isNumber){
163
- const n=Number(raw);
164
- // A non-numeric value in a number field is only reachable via tampering (the browser
165
- // blanks invalid type=number input). Treat it as absent rather than silently submitting
166
- // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
167
- return Number.isFinite(n)?{key,value:n}:null;
168
- }
169
- return {key,value:raw};
170
- }};
171
- }
172
-
173
96
  function complete(userTaskKey,variables){
174
97
  return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
175
98
  body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
@@ -222,42 +145,13 @@ export function mountSurfaces(ctx, app) {
222
145
  method: "GET",
223
146
  path: `${base}/api/form`,
224
147
  source: "surface:taskInbox",
225
- handler: async (req) => {
226
- const formKey = req.query.get("formKey") ?? undefined;
227
- const formId = req.query.get("formId") ?? undefined;
228
- // Reject only when *neither* identifier is present. Presence follows getForm's
229
- // canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
230
- // whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
231
- // truthiness check and returning a spurious 204. The raw values are still passed
232
- // through unchanged — resolving a blank key to its `formId` fallback is getForm's
233
- // single responsibility.
234
- if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
235
- return json({ error: "formKey or formId required" }, 400);
236
- const form = await app.engine.getForm({ formKey, formId });
237
- // A task whose form can't be resolved returns 204: the client renders the
238
- // no-form fallback rather than erroring.
239
- if (!form)
240
- return noContent();
241
- return json(form);
242
- },
148
+ handler: async (req) => resolveFormResponse(app.engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
243
149
  });
244
150
  routes.push({
245
151
  method: "POST",
246
152
  path: `${base}/api/complete`,
247
153
  source: "surface:taskInbox",
248
- handler: async (req) => {
249
- let body;
250
- try {
251
- body = JSON.parse((await req.text()) || "{}");
252
- }
253
- catch {
254
- return json({ error: "invalid JSON body" }, 400);
255
- }
256
- if (!body.userTaskKey)
257
- return json({ error: "userTaskKey required" }, 400);
258
- await app.engine.completeUserTask(body.userTaskKey, body.variables);
259
- return json({ ok: true });
260
- },
154
+ handler: async (req) => completeUserTaskResponse(app.engine, await req.text()),
261
155
  });
262
156
  }
263
157
  const chat = surfaces.chat;
@@ -8,6 +8,9 @@ import { runInJobContext } from "../execContext.js";
8
8
  import { readLineage } from "../lineage.js";
9
9
  import { LineageStore } from "./lineage-store.js";
10
10
  import { readModelRegistry } from "../read-model.js";
11
+ import { registerCanonicalProjections } from "./canonical-projections.js";
12
+ import { InstanceStateStore } from "./instance-state-store.js";
13
+ import { OpenUserTasksStore } from "./open-user-tasks-store.js";
11
14
  import { defaultScheduler, schedulerClock } from "./scheduler.js";
12
15
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
13
16
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
@@ -119,6 +122,28 @@ function tryProvisionReadModelViews(app, log) {
119
122
  log("warn", "read-models: failed to provision managed VIEWs", { error: String(err) });
120
123
  }
121
124
  }
125
+ /**
126
+ * Provision the canonical engine-truth projection sidecars (ADR 0065, proposal point #1) on the app's
127
+ * DEFAULT data source — `_urban_open_user_tasks` and `_urban_instance_state`, the sources a read model
128
+ * `EXISTS`-derives its status edges from. Exactly the per-source boot path `tryLineageStore` uses: an
129
+ * app with no datasource provisions nothing, and a schema failure degrades to no projection tables
130
+ * rather than a failed worker mount. The DSL projection-name registration is done unconditionally by
131
+ * the caller (so read models compile even without a datasource); this only creates the physical tables.
132
+ */
133
+ function tryProvisionCanonicalProjections(app, log) {
134
+ if (!app.data.hasDefaultSource()) {
135
+ log("debug", "canonical-projections: no default data source; projection tables not provisioned");
136
+ return;
137
+ }
138
+ try {
139
+ const { db } = app.data.source();
140
+ new OpenUserTasksStore(db).ensureSchema();
141
+ new InstanceStateStore(db).ensureSchema();
142
+ }
143
+ catch (err) {
144
+ log("warn", "canonical-projections: failed to provision projection tables", { error: String(err) });
145
+ }
146
+ }
122
147
  /** Record an activated job's lineage edge, never letting a projection write break the job. */
123
148
  function recordJobLineage(store, job, log) {
124
149
  try {
@@ -169,6 +194,14 @@ export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
169
194
  // The framework read-model VIEWs (ADR 0065): apply every registered managed VIEW to the app's
170
195
  // default data source, the same per-source boot path lineage uses. A no-op until a sibling task
171
196
  // registers a read model into `readModelRegistry`.
197
+ //
198
+ // Register the canonical engine-truth projections (`urban_open_user_tasks`, `urban_instance_state`)
199
+ // FIRST and unconditionally: a read model's managed VIEW resolves each projection name to its
200
+ // physical `_urban_` table at DDL-compile time, so the name→table mapping must exist before
201
+ // `tryProvisionReadModelViews` compiles any VIEW that `EXISTS`-references a projection. Then
202
+ // provision the projection tables per source (absent-safe), and finally the read-model VIEWs.
203
+ registerCanonicalProjections();
204
+ tryProvisionCanonicalProjections(app, ctx.host.log);
172
205
  tryProvisionReadModelViews(app, ctx.host.log);
173
206
  // Cache module loads so a multi-type handler module is imported once.
174
207
  const moduleCache = new Map();
@@ -42,6 +42,11 @@ export { LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE, LINEAGE_SCHEMA_SQL, Lin
42
42
  export type { Clock as LineageClock, LineageJobLike, LineageStoreOptions, } from "./core/modules/lineage-store.ts";
43
43
  export { and, assertReadModelParity, assertSqlIdentifier, caseWhen, col, compileToFn, compileToSqlSelect, defineReadModel, deriveReadModelViewDdl, eq, exists, gt, gte, lit, lt, lte, neq, not, or, pcol, ProjectionRegistry, projectionRegistry, ReadModelRegistry, readModelRegistry, when, } from "./core/read-model.ts";
44
44
  export type { AndExpr, BaseRow, CaseExpr, ColExpr, CompareExpr, CompareOp, DerivationFn, ExistsExpr, Expr, Literal, LitExpr, NotExpr, OrExpr, ParityDb, ParityOptions, ParitySample, ProjColExpr, ProjectionRows, ProjectionSource, ReadModel, ReadModelDecl, SqlCompileOptions, WhenClause, } from "./core/read-model.ts";
45
+ export { CANONICAL_PROJECTIONS, registerCanonicalProjections, } from "./core/modules/canonical-projections.ts";
46
+ export { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_SCHEMA_SQL, OPEN_USER_TASKS_TABLE, OpenUserTasksStore, } from "./core/modules/open-user-tasks-store.ts";
47
+ export type { OpenUserTaskRow, OpenUserTasksStoreOptions, } from "./core/modules/open-user-tasks-store.ts";
48
+ export { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_SCHEMA_SQL, INSTANCE_STATE_TABLE, InstanceStateStore, } from "./core/modules/instance-state-store.ts";
49
+ export type { InstanceStateRow, InstanceStateStoreOptions, } from "./core/modules/instance-state-store.ts";
45
50
  export { buildScopeIndex, derivePhase, deriveInstancePhases, furthestReached, rollupLineagePhase, SCOPE_CONTAINERS, } from "./core/phase.ts";
46
51
  export type { CrumbKind, CrumbSource, DerivePhaseOptions, FurthestReached, InstancePhase, LineagePhaseRollup, Phase, PhaseCrumb, PhaseGranularity, ProvenanceProgressRow, ScopeElement, ScopeIndex, } from "./core/phase.ts";
47
52
  export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
@@ -53,7 +53,14 @@ export { LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE, LINEAGE_SCHEMA_SQL, Lin
53
53
  // • `projectionRegistry` — register a canonical engine-truth projection NAME usable in `exists(...)`.
54
54
  // The `canonical-projections` task registers `urban_open_user_tasks` / `urban_instance_state`.
55
55
  export { and, assertReadModelParity, assertSqlIdentifier, caseWhen, col, compileToFn, compileToSqlSelect, defineReadModel, deriveReadModelViewDdl, eq, exists, gt, gte, lit, lt, lte, neq, not, or, pcol, ProjectionRegistry, projectionRegistry, ReadModelRegistry, readModelRegistry, when, } from "./core/read-model.js";
56
- // Phase — the framework-level *domain phase* primitive (issue #266): derive "which phase is this
56
+ // Canonical engine-truth projection sidecars (ADR 0065, proposal point #1): two framework-owned,
57
+ // per-source projections the DSL `exists(...)` derives over — `urban_open_user_tasks` (which instances
58
+ // are parked on a human) and `urban_instance_state` (per-instance engine lifecycle state). Provisioned
59
+ // next to the lineage sidecar in core/modules/workers.ts; registered into `projectionRegistry` under
60
+ // their stable DSL names via `registerCanonicalProjections()`.
61
+ export { CANONICAL_PROJECTIONS, registerCanonicalProjections, } from "./core/modules/canonical-projections.js";
62
+ export { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_SCHEMA_SQL, OPEN_USER_TASKS_TABLE, OpenUserTasksStore, } from "./core/modules/open-user-tasks-store.js";
63
+ export { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_SCHEMA_SQL, INSTANCE_STATE_TABLE, InstanceStateStore, } from "./core/modules/instance-state-store.js";
57
64
  // instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
58
65
  // optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
59
66
  export { buildScopeIndex, derivePhase, deriveInstancePhases, furthestReached, rollupLineagePhase, SCOPE_CONTAINERS, } from "./core/phase.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.78.0",
3
+ "version": "0.80.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",
@@ -0,0 +1,114 @@
1
+ // A small fake DOM for unit-testing the browser runtime's element-building code
2
+ // (the shared form-js renderer and the dataGrid engine-form path) under Node,
3
+ // without a real browser. Records every created node so a test can grab an input
4
+ // or button and fire its listeners. Kept intentionally minimal — it supports only
5
+ // the DOM surface those functions touch.
6
+
7
+ export class FakeElement {
8
+ tagName: string;
9
+ attributes: Record<string, string> = {};
10
+ className = "";
11
+ children: Array<FakeElement | { text: string }> = [];
12
+ textContent = "";
13
+ hidden = false;
14
+ disabled = false;
15
+ value = "";
16
+ checked = false;
17
+ type = "";
18
+ name = "";
19
+ id = "";
20
+ listeners: Record<string, Array<(ev: unknown) => unknown>> = {};
21
+
22
+ constructor(tag: string) {
23
+ this.tagName = String(tag).toUpperCase();
24
+ }
25
+ setAttribute(k: string, v: unknown): void {
26
+ this.attributes[k] = String(v);
27
+ if (k === "class") this.className = String(v);
28
+ }
29
+ getAttribute(k: string): string | null {
30
+ return k in this.attributes ? this.attributes[k] : null;
31
+ }
32
+ addEventListener(type: string, fn: (ev: unknown) => unknown): void {
33
+ (this.listeners[type] ||= []).push(fn);
34
+ }
35
+ async fire(type: string, ev: unknown = { preventDefault() {}, stopPropagation() {} }): Promise<void> {
36
+ for (const fn of this.listeners[type] || []) await fn(ev);
37
+ }
38
+ append(...kids: Array<FakeElement | { text: string } | string>): void {
39
+ for (const kid of kids) {
40
+ if (typeof kid === "string") {
41
+ this.children.push({ text: kid });
42
+ this.textContent += kid;
43
+ } else if ("text" in kid) {
44
+ this.children.push(kid);
45
+ this.textContent += kid.text;
46
+ } else {
47
+ this.children.push(kid);
48
+ this.textContent += kid.textContent || "";
49
+ }
50
+ }
51
+ }
52
+ appendChild(kid: FakeElement | { text: string }): void {
53
+ this.append(kid);
54
+ }
55
+ replaceChildren(...kids: Array<FakeElement | { text: string } | string>): void {
56
+ this.children = [];
57
+ this.textContent = "";
58
+ this.append(...kids);
59
+ }
60
+ get firstChild(): FakeElement | { text: string } | null {
61
+ return this.children[0] ?? null;
62
+ }
63
+ querySelector(selector: string): FakeElement | null {
64
+ const walk = (nodes: Array<FakeElement | { text: string }>): FakeElement | null => {
65
+ for (const kid of nodes) {
66
+ if (kid instanceof FakeElement) {
67
+ if (fakeSelectorMatches(kid, selector)) return kid;
68
+ const found = walk(kid.children);
69
+ if (found) return found;
70
+ }
71
+ }
72
+ return null;
73
+ };
74
+ return walk(this.children);
75
+ }
76
+ }
77
+
78
+ // Minimal CSS-selector matcher for the DOM surface the browser runtime touches
79
+ // (currently the radio group's `input:checked` lookup). Supports an optional tag
80
+ // name plus `:checked`; any other pseudo/selector deliberately fails to match so
81
+ // unsupported queries surface rather than silently returning a wrong node.
82
+ function fakeSelectorMatches(node: FakeElement, selector: string): boolean {
83
+ const parts = selector.trim().split(":");
84
+ const tag = parts[0];
85
+ if (tag && node.tagName !== tag.toUpperCase()) return false;
86
+ for (const pseudo of parts.slice(1)) {
87
+ if (pseudo === "checked") {
88
+ if (!node.checked) return false;
89
+ } else {
90
+ return false;
91
+ }
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /** Install a fake `document` on globalThis; returns a restore function. */
97
+ export function installFakeDom(created: FakeElement[]): () => void {
98
+ const doc = {
99
+ createElement: (tag: string) => {
100
+ const n = new FakeElement(tag);
101
+ created.push(n);
102
+ return n;
103
+ },
104
+ createTextNode: (text: string) => ({ text: String(text) }),
105
+ getElementById: () => null,
106
+ dispatchEvent: () => true,
107
+ };
108
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "document");
109
+ Reflect.set(globalThis, "document", doc);
110
+ return () => {
111
+ if (prior) Reflect.defineProperty(globalThis, "document", prior);
112
+ else Reflect.deleteProperty(globalThis, "document");
113
+ };
114
+ }
@@ -0,0 +1,192 @@
1
+ // @ts-check
2
+ // ── Shared, XSS-safe form-js schema renderer (ADR 0026, extended by #457) ─────
3
+ // This is the ONE client implementation of "render a deployed `.form`'s form-js
4
+ // schema and collect its field values", shared verbatim by BOTH:
5
+ // • the `taskInbox` surface (surfaces.ts `inboxPage`), and
6
+ // • the pages `dataGrid` engine-form detail (runtime.browser.js).
7
+ // It used to live inline in surfaces.ts; factoring it here means the two surfaces
8
+ // can never fork the renderer or drift on how a form-js component maps to an input
9
+ // and its submitted value.
10
+ //
11
+ // Like the rest of the browser runtime it is authored/type-checked/linted/tested
12
+ // as real source and served as a generated string artifact (formjs.gen.ts, emitted
13
+ // by scripts/gen-runtime.mjs). It performs NO DOM access at load — every DOM touch
14
+ // is inside a function — so it is import-safe under Node/Deno for unit testing.
15
+ //
16
+ // SECURITY: every value the engine returns is placed via textContent / setAttribute
17
+ // (never innerHTML), so nothing in the form schema can inject markup. The submitted
18
+ // `variables` bag is null-prototype so an engine-supplied component key of
19
+ // `__proto__`/`constructor` lands as a plain own property and can never mutate a
20
+ // prototype (prototype-pollution class).
21
+
22
+ /** Create an element, setting attributes via setAttribute and text via textContent only.
23
+ * @param {string} tag
24
+ * @param {Record<string, string>} [attrs]
25
+ * @param {string} [text]
26
+ * @returns {any}
27
+ */
28
+ function fjEl(tag, attrs, text) {
29
+ const e = document.createElement(tag);
30
+ if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);
31
+ if (text != null) e.textContent = text;
32
+ return e;
33
+ }
34
+
35
+ /** A plain type="button" with an optional click handler.
36
+ * @param {string} label
37
+ * @param {(() => void) | null} [onClick]
38
+ * @returns {any}
39
+ */
40
+ function fjBtn(label, onClick) {
41
+ const b = document.createElement("button");
42
+ b.type = "button";
43
+ b.textContent = label;
44
+ if (onClick) b.addEventListener("click", onClick);
45
+ return b;
46
+ }
47
+
48
+ /** Build one form-js component into its field element and (for keyed inputs) a reader
49
+ * that returns the entered `{ key, value }` or null when blank. Static (keyless)
50
+ * components render as inert text; layout-only components are skipped (null).
51
+ * @param {any} c
52
+ * @returns {{ field: any, read?: () => ({ key: string, value: any } | null) } | null}
53
+ */
54
+ function buildField(c) {
55
+ const type = c && c.type;
56
+ // Static (keyless) components: show text as a paragraph, ignore layout-only ones.
57
+ if (type === "text") {
58
+ return { field: fjEl("p", { class: "njf-text" }, typeof c.text === "string" ? c.text : "") };
59
+ }
60
+ const key = c && c.key;
61
+ if (!key) return null;
62
+ const wrap = fjEl("div", { class: "njf-field" });
63
+ const label = typeof c.label === "string" && c.label ? c.label : key;
64
+ if (type === "checkbox") {
65
+ const input = document.createElement("input");
66
+ input.type = "checkbox";
67
+ const lab = fjEl("label", {});
68
+ lab.appendChild(input);
69
+ lab.appendChild(document.createTextNode(" " + label));
70
+ wrap.appendChild(lab);
71
+ return { field: wrap, read: () => ({ key, value: input.checked }) };
72
+ }
73
+ const labelEl = fjEl("label", {}, label);
74
+ wrap.appendChild(labelEl);
75
+ let input;
76
+ if (type === "textarea") {
77
+ input = document.createElement("textarea");
78
+ } else if (type === "select") {
79
+ input = document.createElement("select");
80
+ input.appendChild(fjEl("option", { value: "" }, "\u2014"));
81
+ for (const o of Array.isArray(c.values) ? c.values : []) {
82
+ input.appendChild(fjEl("option", { value: String(o.value) }, String(o.label != null ? o.label : o.value)));
83
+ }
84
+ } else if (type === "radio") {
85
+ const name = "r" + Math.random().toString(36).slice(2);
86
+ const group = fjEl("div", {});
87
+ for (const o of Array.isArray(c.values) ? c.values : []) {
88
+ const rlab = fjEl("label", {});
89
+ const r = document.createElement("input");
90
+ r.type = "radio";
91
+ r.name = name;
92
+ r.value = String(o.value);
93
+ rlab.appendChild(r);
94
+ rlab.appendChild(document.createTextNode(" " + String(o.label != null ? o.label : o.value)));
95
+ group.appendChild(rlab);
96
+ }
97
+ wrap.appendChild(group);
98
+ return {
99
+ field: wrap,
100
+ read: () => {
101
+ const sel = group.querySelector("input:checked");
102
+ return sel ? { key, value: sel.value } : null;
103
+ },
104
+ };
105
+ } else {
106
+ input = document.createElement("input");
107
+ input.type = type === "number" ? "number" : type === "datetime" ? "datetime-local" : "text";
108
+ }
109
+ // Associate the label with the input (for/id) so screen readers announce it on focus.
110
+ const fieldId = "f" + Math.random().toString(36).slice(2);
111
+ input.id = fieldId;
112
+ labelEl.setAttribute("for", fieldId);
113
+ wrap.appendChild(input);
114
+ const isNumber = type === "number";
115
+ return {
116
+ field: wrap,
117
+ read: () => {
118
+ const raw = input.value;
119
+ if (raw === "" || raw == null) return null;
120
+ if (isNumber) {
121
+ const n = Number(raw);
122
+ // A non-numeric value in a number field is only reachable via tampering (the browser
123
+ // blanks invalid type=number input). Treat it as absent rather than silently submitting
124
+ // NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
125
+ return Number.isFinite(n) ? { key, value: n } : null;
126
+ }
127
+ return { key, value: raw };
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Render a form-js schema into a `<form>` element with a submit + optional cancel.
134
+ * On submit it assembles every keyed field's value into one null-prototype
135
+ * `variables` bag and calls `opts.onSubmit(variables)`; a rejected submit re-enables
136
+ * the button so the operator can retry. `opts.onCancel`, when supplied, wires a
137
+ * cancel button.
138
+ * @param {any} schema the parsed form-js document (`{ components: [...] }`)
139
+ * @param {{ heading?: string, submitLabel?: string, cancelLabel?: string,
140
+ * onSubmit: (variables: Record<string, any>) => (void | Promise<void>),
141
+ * onCancel?: () => void }} opts
142
+ * @returns {any} the built <form> element
143
+ */
144
+ function renderForm(schema, opts) {
145
+ const form = document.createElement("form");
146
+ form.className = "njf-form";
147
+ if (opts.heading) form.appendChild(fjEl("h2", { class: "njf-heading" }, opts.heading));
148
+ const components = schema && Array.isArray(schema.components) ? schema.components : [];
149
+ /** @type {Array<() => ({ key: string, value: any } | null)>} */
150
+ const inputs = [];
151
+ for (const c of components) {
152
+ const built = buildField(c);
153
+ if (!built) continue;
154
+ form.appendChild(built.field);
155
+ if (built.read) inputs.push(built.read);
156
+ }
157
+ const submit = fjBtn(opts.submitLabel || "Submit", null);
158
+ submit.type = "submit";
159
+ const bar = fjEl("div", { class: "njf-actions" });
160
+ bar.appendChild(submit);
161
+ if (opts.onCancel) bar.appendChild(fjBtn(opts.cancelLabel || "Cancel", opts.onCancel));
162
+ form.appendChild(bar);
163
+ form.addEventListener("submit", (e) => {
164
+ e.preventDefault();
165
+ // Null-prototype: field keys come from the engine-supplied form schema. A component
166
+ // keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
167
+ // through JSON), never mutate a prototype — no prototype pollution in the page runtime.
168
+ const variables = Object.create(null);
169
+ for (const read of inputs) {
170
+ const kv = read();
171
+ if (kv) variables[kv.key] = kv.value;
172
+ }
173
+ submit.disabled = true;
174
+ Promise.resolve()
175
+ .then(() => opts.onSubmit(variables))
176
+ .catch(() => {
177
+ submit.disabled = false;
178
+ });
179
+ });
180
+ return form;
181
+ }
182
+
183
+ export { buildField, renderForm };
184
+
185
+ // Expose the renderer as a browser global so both self-contained page bundles can
186
+ // reach the ONE implementation without importing it: the taskInbox page embeds this
187
+ // module inline and the pages shell does the same before loading the ES-module
188
+ // runtime, which reads `globalThis.NanoFormJs` lazily when it renders an engine form.
189
+ // Guarded so importing under Node/Deno for tests is a harmless no-op-ish assignment.
190
+ if (typeof globalThis !== "undefined") {
191
+ /** @type {any} */ (globalThis).NanoFormJs = { buildField, renderForm };
192
+ }