@nanobpm/urban 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
|
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
|
|
|
29
29
|
cancelInstance: async () => {},
|
|
30
30
|
publishMessage: async () => {},
|
|
31
31
|
searchUserTasks: async () => [],
|
|
32
|
+
getForm: async () => null,
|
|
32
33
|
completeUserTask: async () => {},
|
|
33
34
|
searchProcessInstances: async () => [],
|
|
34
35
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -43,6 +44,7 @@ const fakeEngine: EngineClient = {
|
|
|
43
44
|
cancelInstance: async () => {},
|
|
44
45
|
publishMessage: async () => {},
|
|
45
46
|
searchUserTasks: async () => [],
|
|
47
|
+
getForm: async () => null,
|
|
46
48
|
completeUserTask: async () => {},
|
|
47
49
|
searchProcessInstances: async () => [],
|
|
48
50
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -76,13 +78,204 @@ test("chat agent is HTML-escaped in the mount page", async () => {
|
|
|
76
78
|
assert.ok(body.includes("<img src=x onerror=alert(1)>"), "agent is escaped");
|
|
77
79
|
});
|
|
78
80
|
|
|
79
|
-
test("task-inbox
|
|
81
|
+
test("task-inbox client derives its API base from location (reverse-proxy safe, no path embedding)", async () => {
|
|
82
|
+
// The client must NOT embed the manifest-supplied route base as an absolute
|
|
83
|
+
// literal: a hardcoded "/tasks" escapes the Nano console's path-prefixed
|
|
84
|
+
// reverse proxy (/console/app-view/<name>/tasks) — every fetch to
|
|
85
|
+
// "/tasks/api/…" 404s upstream. Deriving the base from location.pathname keeps
|
|
86
|
+
// the API calls document-relative (correct at the origin root AND under the
|
|
87
|
+
// proxy) and, as a bonus, removes the manifest→<script> injection surface
|
|
88
|
+
// entirely: nothing manifest-supplied is embedded in the page at all.
|
|
80
89
|
const evil = "/tasks'});alert(1);//x";
|
|
81
90
|
const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: evil } }), fakeApp);
|
|
82
91
|
const page = s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && !r.path.endsWith("/api/tasks"))!;
|
|
83
92
|
const body = await render(page);
|
|
84
|
-
// The path
|
|
85
|
-
|
|
86
|
-
assert.ok(!body.includes(
|
|
87
|
-
|
|
93
|
+
// The manifest path (evil or otherwise) is never emitted into the page.
|
|
94
|
+
assert.ok(!body.includes("alert(1)"), "manifest path is not embedded — nothing to inject");
|
|
95
|
+
assert.ok(!body.includes(JSON.stringify(evil)), "manifest path is not embedded as a JS literal");
|
|
96
|
+
// The base is derived from where the page was actually served, so it inherits
|
|
97
|
+
// any reverse-proxy path prefix instead of a root-absolute mount path.
|
|
98
|
+
assert.ok(body.includes("location.pathname"), "client derives its API base from location.pathname");
|
|
99
|
+
assert.ok(!/const\s+BASE\s*=\s*["'/]/.test(body), "client does not embed an absolute route base");
|
|
88
100
|
});
|
|
101
|
+
|
|
102
|
+
/** Build an app handle backed by a specific engine (the flow tests drive the routes). */
|
|
103
|
+
function appWith(engine: EngineClient): Parameters<typeof mountSurfaces>[1] {
|
|
104
|
+
return {
|
|
105
|
+
manifest: { schemaVersion: 1, id: "app", name: "App" },
|
|
106
|
+
data: new DataLayer(new Map(), undefined, {}),
|
|
107
|
+
engine,
|
|
108
|
+
env: () => undefined,
|
|
109
|
+
log: createLogger(() => {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Invoke a route handler with an optional query string and POST body. */
|
|
114
|
+
async function call(
|
|
115
|
+
route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> },
|
|
116
|
+
opts: { method?: string; query?: string; body?: string } = {},
|
|
117
|
+
): Promise<HttpResponse> {
|
|
118
|
+
return route.handler({
|
|
119
|
+
method: opts.method ?? "GET",
|
|
120
|
+
path: "/",
|
|
121
|
+
query: new URLSearchParams(opts.query ?? ""),
|
|
122
|
+
headers: new Headers(),
|
|
123
|
+
text: async () => opts.body ?? "",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function inboxRoutes(engine: EngineClient) {
|
|
128
|
+
const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: "/tasks" } }), appWith(engine));
|
|
129
|
+
const at = (suffix: string, method = "GET") =>
|
|
130
|
+
s.routes.find(
|
|
131
|
+
(r) => r.source === "surface:taskInbox" && r.method === method && r.path.endsWith(suffix),
|
|
132
|
+
)!;
|
|
133
|
+
return {
|
|
134
|
+
page: s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && r.path === "/tasks")!,
|
|
135
|
+
tasks: at("/api/tasks"),
|
|
136
|
+
form: at("/api/form"),
|
|
137
|
+
complete: at("/api/complete", "POST"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test("inbox page renders the client-side form fetch + renderer", async () => {
|
|
142
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
143
|
+
const rendered = String((await call(page)).body);
|
|
144
|
+
assert.ok(rendered.includes("/api/form"), "page fetches the linked form");
|
|
145
|
+
assert.ok(rendered.includes("function renderForm"), "page has a form renderer");
|
|
146
|
+
assert.ok(rendered.includes("function buildField"), "page builds fields from the schema");
|
|
147
|
+
assert.ok(rendered.includes("/api/complete"), "page posts completion");
|
|
148
|
+
// Drift guard: the client script is a stringified <script> body, so it is invisible to
|
|
149
|
+
// the type checker. UserTaskSummary carries `formKey`/`externalFormReference` but no
|
|
150
|
+
// `formId`, so the client must never branch on a task-supplied `formId` (that phantom
|
|
151
|
+
// field would silently be `undefined`). Lock the drift closed.
|
|
152
|
+
assert.ok(!rendered.includes("formId"), "client script must not reference formId — UserTaskSummary carries none");
|
|
153
|
+
// Prototype-pollution guard: submit builds `variables` from engine-supplied component
|
|
154
|
+
// keys (a schema could key a field '__proto__'/'constructor'). It must use a null-
|
|
155
|
+
// prototype bag so an untrusted key lands as an own property, never mutating a prototype.
|
|
156
|
+
assert.ok(rendered.includes("Object.create(null)"), "client builds the variables bag with a null prototype");
|
|
157
|
+
assert.ok(!/const\s+variables\s*=\s*\{\}/.test(rendered), "client must not collect untrusted keys into a plain {} object");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("/api/tasks surfaces the resolved form linkage", async () => {
|
|
161
|
+
let seenFilter: unknown;
|
|
162
|
+
const engine: EngineClient = {
|
|
163
|
+
...fakeEngine,
|
|
164
|
+
searchUserTasks: async (filter) => {
|
|
165
|
+
seenFilter = filter;
|
|
166
|
+
return [
|
|
167
|
+
{ userTaskKey: "1", elementId: "approve", formKey: "form-123" },
|
|
168
|
+
{ userTaskKey: "2", elementId: "review" },
|
|
169
|
+
];
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
const { tasks } = inboxRoutes(engine);
|
|
173
|
+
const res = await call(tasks, { query: "processInstanceKey=pi-9" });
|
|
174
|
+
assert.deepEqual(seenFilter, { processInstanceKey: "pi-9" });
|
|
175
|
+
const parsed = JSON.parse(String(res.body));
|
|
176
|
+
assert.equal(parsed[0].formKey, "form-123");
|
|
177
|
+
assert.equal(parsed[1].formKey, undefined);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("/api/form resolves a linked form's schema", async () => {
|
|
181
|
+
let seen: unknown;
|
|
182
|
+
const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name", label: "Name" }] };
|
|
183
|
+
const engine: EngineClient = {
|
|
184
|
+
...fakeEngine,
|
|
185
|
+
getForm: async (input) => {
|
|
186
|
+
seen = input;
|
|
187
|
+
return { formKey: "form-123", schema };
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const { form } = inboxRoutes(engine);
|
|
191
|
+
const res = await call(form, { query: "formKey=form-123" });
|
|
192
|
+
assert.deepEqual(seen, { formKey: "form-123", formId: undefined });
|
|
193
|
+
assert.equal(res.status, 200);
|
|
194
|
+
assert.deepEqual(JSON.parse(String(res.body)).schema, schema);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("/api/form requires an identifier", async () => {
|
|
198
|
+
const { form } = inboxRoutes(fakeEngine);
|
|
199
|
+
const res = await call(form);
|
|
200
|
+
assert.equal(res.status, 400);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("/api/form passes an empty formKey through so getForm can fall back to formId", async () => {
|
|
204
|
+
// A `?formKey=&formId=…` request must not be rejected at the boundary (only *both*
|
|
205
|
+
// identifiers absent is a 400) nor stripped here — resolving an empty key to its formId
|
|
206
|
+
// fallback is getForm's single responsibility (see nanosdk getForm empty/whitespace test).
|
|
207
|
+
let seen: unknown;
|
|
208
|
+
const engine: EngineClient = {
|
|
209
|
+
...fakeEngine,
|
|
210
|
+
getForm: async (input) => {
|
|
211
|
+
seen = input;
|
|
212
|
+
return { formKey: "form-9", schema: { type: "default", components: [] } };
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const { form } = inboxRoutes(engine);
|
|
216
|
+
const res = await call(form, { query: "formKey=&formId=form-9" });
|
|
217
|
+
assert.equal(res.status, 200);
|
|
218
|
+
assert.deepEqual(seen, { formKey: "", formId: "form-9" });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("/api/form 400s a whitespace-only identifier instead of a spurious 204", async () => {
|
|
222
|
+
// A `?formKey= ` (or `formId= `) request provides no usable identifier. The route's
|
|
223
|
+
// presence gate must follow getForm's canonical rule (whitespace = absent) and 400,
|
|
224
|
+
// rather than letting the blank key slip past a raw truthiness check to getForm (→ null
|
|
225
|
+
// → 204). Guards the route↔getForm presence drift on the taskInbox form path — Red when
|
|
226
|
+
// the gate checks raw `!formKey && !formId`, since " " is truthy.
|
|
227
|
+
const { form } = inboxRoutes(fakeEngine);
|
|
228
|
+
assert.equal((await call(form, { query: "formKey=%20%20%20" })).status, 400);
|
|
229
|
+
assert.equal((await call(form, { query: "formId=%20%20" })).status, 400);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("/api/form returns 204 for a task with no resolvable form (no-form fallback)", async () => {
|
|
233
|
+
const engine: EngineClient = { ...fakeEngine, getForm: async () => null };
|
|
234
|
+
const { form } = inboxRoutes(engine);
|
|
235
|
+
const res = await call(form, { query: "formKey=missing" });
|
|
236
|
+
assert.equal(res.status, 204);
|
|
237
|
+
// The body must be empty: a "null" payload (json(null, 204)) makes the client's
|
|
238
|
+
// fetch helper throw on parse and surface "Failed to load form" instead of the
|
|
239
|
+
// no-form fallback. Guard the wire shape here and the client short-circuit below.
|
|
240
|
+
assert.equal(res.body, "");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("inbox client fetch helper short-circuits 204 so the no-form fallback renders", async () => {
|
|
244
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
245
|
+
const rendered = String((await call(page)).body);
|
|
246
|
+
// The api() helper must not call r.json() on a 204 (empty body → throw). It has
|
|
247
|
+
// to resolve to null so openForm() falls through to renderNoForm(t).
|
|
248
|
+
assert.ok(rendered.includes("r.status===204"), "api() short-circuits a 204 response");
|
|
249
|
+
assert.ok(rendered.includes("function renderNoForm"), "page has a no-form renderer");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("number field reader guards against a non-numeric (tampered) value", async () => {
|
|
253
|
+
// A number field submits `Number(raw)`; a non-numeric value (only reachable via tampering
|
|
254
|
+
// — the browser blanks invalid type=number input) yields NaN, which JSON.stringify
|
|
255
|
+
// serializes as null, silently changing the submission. The inline reader must guard it
|
|
256
|
+
// with Number.isFinite so a bad value is treated as absent rather than submitted as NaN.
|
|
257
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
258
|
+
const rendered = String((await call(page)).body);
|
|
259
|
+
assert.ok(rendered.includes("Number.isFinite"), "number field guards NaN via Number.isFinite");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("/api/complete completes the task with the submitted variables", async () => {
|
|
263
|
+
const calls: { key: string; variables?: Record<string, unknown> }[] = [];
|
|
264
|
+
const engine: EngineClient = {
|
|
265
|
+
...fakeEngine,
|
|
266
|
+
completeUserTask: async (key, variables) => {
|
|
267
|
+
calls.push({ key, variables });
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const { complete } = inboxRoutes(engine);
|
|
271
|
+
const res = await call(complete, { method: "POST", body: JSON.stringify({ userTaskKey: "7", variables: { name: "Ada", agree: true } }) });
|
|
272
|
+
assert.equal(res.status, 200);
|
|
273
|
+
assert.deepEqual(calls, [{ key: "7", variables: { name: "Ada", agree: true } }]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("/api/complete rejects a body with no userTaskKey", async () => {
|
|
277
|
+
const { complete } = inboxRoutes(fakeEngine);
|
|
278
|
+
const res = await call(complete, { method: "POST", body: JSON.stringify({ variables: {} }) });
|
|
279
|
+
assert.equal(res.status, 400);
|
|
280
|
+
});
|
|
281
|
+
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// surfaces — the hosted human surfaces (ADR 0026). Implements the `taskInbox` surface: a
|
|
2
|
-
// small JSON+HTML task list backed by the engine's user-task search
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
2
|
+
// small JSON+HTML task list backed by the engine's user-task search. Each open task links to
|
|
3
|
+
// its deployed `.form` via the engine-resolved `formKey`; the list fetches that form's form-js
|
|
4
|
+
// schema (`GET base/api/form`), renders its fields client-side with a minimal built-in
|
|
5
|
+
// renderer, and posts the entered field values as the completion `variables` (`POST
|
|
6
|
+
// base/api/complete`). A task with no linked form keeps the bare key-list + raw-complete
|
|
7
|
+
// behavior. The `chat` surface is stubbed as a mount point (LLM wiring is a separate concern).
|
|
8
|
+
// Surfaces contribute routes to the shared server.
|
|
5
9
|
|
|
6
10
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
7
|
-
import {
|
|
11
|
+
import { presentFormIdentifier } from "../host.ts";
|
|
12
|
+
import { html, json, noContent, normalizeRoutePath, type Route } from "../router.ts";
|
|
8
13
|
import { mountActions } from "./actions.ts";
|
|
9
14
|
import { mountApi } from "./api.ts";
|
|
10
15
|
import { mountPages } from "./pages.ts";
|
|
@@ -22,23 +27,169 @@ export interface SurfacesHandle {
|
|
|
22
27
|
describe(): Record<string, unknown>;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
function inboxPage(
|
|
30
|
+
function inboxPage(): string {
|
|
31
|
+
// Reverse-proxy safe: the client derives its API base from location.pathname
|
|
32
|
+
// (where THIS page was actually served) rather than an embedded absolute
|
|
33
|
+
// route base. A hardcoded "/tasks" would escape the Nano console's
|
|
34
|
+
// path-prefixed proxy (/console/app-view/<name>/tasks) — the API fetches must
|
|
35
|
+
// inherit that prefix. Deriving from location also removes the manifest→
|
|
36
|
+
// <script> injection surface entirely: nothing manifest-supplied is embedded.
|
|
37
|
+
// All task/form values are rendered via textContent / setAttribute — never
|
|
38
|
+
// innerHTML — so nothing the engine returns can inject markup.
|
|
26
39
|
return `<!doctype html><meta charset="utf-8"><title>Task inbox</title>
|
|
27
40
|
<style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:52rem}
|
|
28
|
-
li{margin:.4rem 0
|
|
41
|
+
ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
|
|
42
|
+
code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
|
|
43
|
+
button{font:inherit;padding:.2rem .6rem;cursor:pointer}
|
|
44
|
+
form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
|
|
45
|
+
form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
|
|
46
|
+
form .field input[type=checkbox],form .field input[type=radio]{width:auto}
|
|
47
|
+
#form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
|
|
48
|
+
.muted{color:#666}</style>
|
|
29
49
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
50
|
+
<div id="form"></div>
|
|
30
51
|
<script>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
const BASE=location.pathname.replace(/\/+$/,'');
|
|
53
|
+
const tasksEl=document.getElementById('tasks');
|
|
54
|
+
const formEl=document.getElementById('form');
|
|
55
|
+
|
|
56
|
+
function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.text().then(t=>{throw new Error(t||r.status)});if(r.status===204)return null;return r.json();});}
|
|
57
|
+
|
|
58
|
+
function loadTasks(){
|
|
59
|
+
formEl.replaceChildren();
|
|
60
|
+
tasksEl.replaceChildren(el('li',{},'loading…'));
|
|
61
|
+
return api('/api/tasks').then(ts=>{
|
|
62
|
+
tasksEl.replaceChildren();
|
|
63
|
+
if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
|
|
64
|
+
for(const t of ts){
|
|
65
|
+
const li=document.createElement('li');
|
|
66
|
+
const code=el('code',{},t.elementId||t.userTaskKey);
|
|
67
|
+
li.appendChild(code);
|
|
68
|
+
li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
|
|
69
|
+
if(t.formKey){
|
|
70
|
+
li.appendChild(btn('Open',()=>openForm(t)));
|
|
71
|
+
}else{
|
|
72
|
+
li.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
|
|
73
|
+
}
|
|
74
|
+
tasksEl.appendChild(li);
|
|
75
|
+
}
|
|
76
|
+
}).catch(err=>tasksEl.replaceChildren(el('li',{class:'muted'},'Failed to load tasks: '+err.message)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function openForm(t){
|
|
80
|
+
formEl.replaceChildren(el('p',{class:'muted'},'loading form…'));
|
|
81
|
+
const q='formKey='+encodeURIComponent(t.formKey);
|
|
82
|
+
api('/api/form?'+q).then(f=>{
|
|
83
|
+
formEl.replaceChildren();
|
|
84
|
+
if(!f||!f.schema){renderNoForm(t);return;}
|
|
85
|
+
renderForm(t,f.schema);
|
|
86
|
+
}).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderNoForm(t){
|
|
90
|
+
formEl.appendChild(el('p',{class:'muted'},'This task has no renderable form.'));
|
|
91
|
+
const bar=el('div',{});
|
|
92
|
+
bar.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
|
|
93
|
+
bar.appendChild(btn('Cancel',loadTasks));
|
|
94
|
+
formEl.appendChild(bar);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderForm(t,schema){
|
|
98
|
+
const form=document.createElement('form');
|
|
99
|
+
form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
|
|
100
|
+
const components=Array.isArray(schema.components)?schema.components:[];
|
|
101
|
+
const inputs=[];
|
|
102
|
+
for(const c of components){
|
|
103
|
+
const built=buildField(c);
|
|
104
|
+
if(!built)continue;
|
|
105
|
+
form.appendChild(built.field);
|
|
106
|
+
if(built.read)inputs.push(built.read);
|
|
107
|
+
}
|
|
108
|
+
const submit=btn('Submit',null);submit.type='submit';
|
|
109
|
+
const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
|
|
110
|
+
form.appendChild(bar);
|
|
111
|
+
form.addEventListener('submit',e=>{
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
// Null-prototype: field keys come from the engine-supplied form schema. A component
|
|
114
|
+
// keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
|
|
115
|
+
// through JSON), never mutate a prototype — no prototype pollution in the page runtime.
|
|
116
|
+
const variables=Object.create(null);
|
|
117
|
+
for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
|
|
118
|
+
submit.disabled=true;
|
|
119
|
+
complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
|
|
120
|
+
});
|
|
121
|
+
formEl.appendChild(form);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildField(c){
|
|
125
|
+
const type=c&&c.type;
|
|
126
|
+
// Static (keyless) components: show text as a paragraph, ignore layout-only ones.
|
|
127
|
+
if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
|
|
128
|
+
const key=c&&c.key;
|
|
129
|
+
if(!key)return null;
|
|
130
|
+
const wrap=el('div',{class:'field'});
|
|
131
|
+
const label=typeof c.label==='string'&&c.label?c.label:key;
|
|
132
|
+
if(type==='checkbox'){
|
|
133
|
+
const input=document.createElement('input');input.type='checkbox';
|
|
134
|
+
const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
|
|
135
|
+
wrap.appendChild(lab);
|
|
136
|
+
return {field:wrap,read:()=>({key,value:input.checked})};
|
|
137
|
+
}
|
|
138
|
+
const labelEl=el('label',{},label);
|
|
139
|
+
wrap.appendChild(labelEl);
|
|
140
|
+
let input;
|
|
141
|
+
if(type==='textarea'){input=document.createElement('textarea');}
|
|
142
|
+
else if(type==='select'){
|
|
143
|
+
input=document.createElement('select');
|
|
144
|
+
input.appendChild(el('option',{value:''},'—'));
|
|
145
|
+
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
146
|
+
input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if(type==='radio'){
|
|
150
|
+
const name='r'+Math.random().toString(36).slice(2);
|
|
151
|
+
const group=el('div',{});
|
|
152
|
+
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
153
|
+
const rlab=el('label',{});
|
|
154
|
+
const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
|
|
155
|
+
rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
|
|
156
|
+
group.appendChild(rlab);
|
|
157
|
+
}
|
|
158
|
+
wrap.appendChild(group);
|
|
159
|
+
return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
|
|
160
|
+
}
|
|
161
|
+
else{
|
|
162
|
+
input=document.createElement('input');
|
|
163
|
+
input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
|
|
164
|
+
}
|
|
165
|
+
// Associate the label with the input (for/id) so screen readers announce it on focus.
|
|
166
|
+
const fieldId='f'+Math.random().toString(36).slice(2);
|
|
167
|
+
input.id=fieldId;labelEl.setAttribute('for',fieldId);
|
|
168
|
+
wrap.appendChild(input);
|
|
169
|
+
const isNumber=type==='number';
|
|
170
|
+
return {field:wrap,read:()=>{
|
|
171
|
+
const raw=input.value;
|
|
172
|
+
if(raw===''||raw==null)return null;
|
|
173
|
+
if(isNumber){
|
|
174
|
+
const n=Number(raw);
|
|
175
|
+
// A non-numeric value in a number field is only reachable via tampering (the browser
|
|
176
|
+
// blanks invalid type=number input). Treat it as absent rather than silently submitting
|
|
177
|
+
// NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
|
|
178
|
+
return Number.isFinite(n)?{key,value:n}:null;
|
|
179
|
+
}
|
|
180
|
+
return {key,value:raw};
|
|
181
|
+
}};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function complete(userTaskKey,variables){
|
|
185
|
+
return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
|
|
186
|
+
body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function el(tag,attrs,text){const e=document.createElement(tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(text!=null)e.textContent=text;return e;}
|
|
190
|
+
function btn(label,onClick){const b=document.createElement('button');b.type='button';b.textContent=label;if(onClick)b.addEventListener('click',onClick);return b;}
|
|
191
|
+
|
|
192
|
+
loadTasks();
|
|
42
193
|
</script>`;
|
|
43
194
|
}
|
|
44
195
|
|
|
@@ -56,7 +207,7 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
56
207
|
method: "GET",
|
|
57
208
|
path: base,
|
|
58
209
|
source: "surface:taskInbox",
|
|
59
|
-
handler: () => html(inboxPage(
|
|
210
|
+
handler: () => html(inboxPage()),
|
|
60
211
|
});
|
|
61
212
|
routes.push({
|
|
62
213
|
method: "GET",
|
|
@@ -68,6 +219,28 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
68
219
|
return json(tasks);
|
|
69
220
|
},
|
|
70
221
|
});
|
|
222
|
+
routes.push({
|
|
223
|
+
method: "GET",
|
|
224
|
+
path: `${base}/api/form`,
|
|
225
|
+
source: "surface:taskInbox",
|
|
226
|
+
handler: async (req) => {
|
|
227
|
+
const formKey = req.query.get("formKey") ?? undefined;
|
|
228
|
+
const formId = req.query.get("formId") ?? undefined;
|
|
229
|
+
// Reject only when *neither* identifier is present. Presence follows getForm's
|
|
230
|
+
// canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
|
|
231
|
+
// whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
|
|
232
|
+
// truthiness check and returning a spurious 204. The raw values are still passed
|
|
233
|
+
// through unchanged — resolving a blank key to its `formId` fallback is getForm's
|
|
234
|
+
// single responsibility.
|
|
235
|
+
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
236
|
+
return json({ error: "formKey or formId required" }, 400);
|
|
237
|
+
const form = await app.engine.getForm({ formKey, formId });
|
|
238
|
+
// A task whose form can't be resolved returns 204: the client renders the
|
|
239
|
+
// no-form fallback rather than erroring.
|
|
240
|
+
if (!form) return noContent();
|
|
241
|
+
return json(form);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
71
244
|
routes.push({
|
|
72
245
|
method: "POST",
|
|
73
246
|
path: `${base}/api/complete`,
|
|
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
|
|
|
30
30
|
this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
|
|
31
31
|
}
|
|
32
32
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
33
|
+
async getForm(): Promise<null> { return null; }
|
|
33
34
|
async completeUserTask(): Promise<void> {}
|
|
34
35
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
35
36
|
async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
|
|
@@ -15,6 +15,7 @@ class MiniEngine implements EngineClient {
|
|
|
15
15
|
async cancelInstance(): Promise<void> {}
|
|
16
16
|
async publishMessage(): Promise<void> {}
|
|
17
17
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
18
|
+
async getForm(): Promise<null> { return null; }
|
|
18
19
|
async completeUserTask(): Promise<void> {}
|
|
19
20
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
20
21
|
async close(): Promise<void> {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { normalizeRoutePath } from "./router.ts";
|
|
3
|
+
import { noContent, normalizeRoutePath } from "./router.ts";
|
|
4
4
|
|
|
5
5
|
test("normalizeRoutePath ensures a leading slash and trims trailing slashes", () => {
|
|
6
6
|
assert.equal(normalizeRoutePath("hooks/x", "/fallback"), "/hooks/x");
|
|
@@ -20,3 +20,12 @@ test("normalizeRoutePath falls back when value is missing or collapses to root",
|
|
|
20
20
|
assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
|
|
21
21
|
assert.equal(normalizeRoutePath("", "/tasks"), "/tasks");
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
test("noContent emits a 204 with an empty body and no content-type", () => {
|
|
25
|
+
const res = noContent();
|
|
26
|
+
assert.equal(res.status, 204);
|
|
27
|
+
// 204 must carry no body: a "null" (or any) payload violates the semantics and
|
|
28
|
+
// makes a body-parsing client throw. Guard against a json(null, 204) regression.
|
|
29
|
+
assert.equal(res.body, "");
|
|
30
|
+
assert.equal(res.headers?.["content-type"], undefined);
|
|
31
|
+
});
|
|
@@ -26,6 +26,16 @@ export function text(body: string, status = 200): HttpResponse {
|
|
|
26
26
|
return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A `204 No Content` response: an empty body and no `content-type`. Use this
|
|
31
|
+
* instead of `json(null, 204)` — the latter emits a `"null"` body that both
|
|
32
|
+
* violates the no-body semantics of 204 and makes a client that parses the
|
|
33
|
+
* body throw. Any client that short-circuits 204 receives no payload here.
|
|
34
|
+
*/
|
|
35
|
+
export function noContent(): HttpResponse {
|
|
36
|
+
return { status: 204, headers: {}, body: "" };
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
/**
|
|
30
40
|
* Normalize a manifest-supplied route path: collapse leading slashes to exactly
|
|
31
41
|
* one, drop trailing slashes. Incoming request paths always start with "/", so a
|
|
@@ -41,6 +41,9 @@ class FakeEngine implements EngineClient {
|
|
|
41
41
|
async searchUserTasks() {
|
|
42
42
|
return this.userTasks;
|
|
43
43
|
}
|
|
44
|
+
async getForm() {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
44
47
|
async completeUserTask(key: string, variables?: Record<string, unknown>) {
|
|
45
48
|
this.completedTasks.push({ key, variables });
|
|
46
49
|
}
|
|
@@ -70,7 +73,7 @@ function expectArray(value: unknown): unknown[] {
|
|
|
70
73
|
return value;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
async function makeFixture(): Promise<string> {
|
|
76
|
+
async function makeFixture(extraManifest?: Record<string, unknown>): Promise<string> {
|
|
74
77
|
const dir = await mkdtemp(join(tmpdir(), "urban-rt-"));
|
|
75
78
|
await mkdir(join(dir, "processes"));
|
|
76
79
|
await mkdir(join(dir, "decisions"));
|
|
@@ -103,6 +106,7 @@ async function makeFixture(): Promise<string> {
|
|
|
103
106
|
workers: [{ taskType: "wf.claim", handler: "workers/handlers.ts" }],
|
|
104
107
|
triggers: [{ id: "hook", type: "webhook", path: "/hooks/task", action: { message: "wf.requested", correlationKey: "= body.taskId" } }],
|
|
105
108
|
surfaces: { taskInbox: { enabled: true, path: "/tasks" } },
|
|
109
|
+
...extraManifest,
|
|
106
110
|
};
|
|
107
111
|
await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2));
|
|
108
112
|
return dir;
|
|
@@ -569,6 +573,68 @@ test("treats NANOBPMN_APP_HANDSHAKE other than \"1\" as opt-out", async () => {
|
|
|
569
573
|
}
|
|
570
574
|
});
|
|
571
575
|
|
|
576
|
+
// Read the interface a started app's HTTP server actually bound to (issue #235).
|
|
577
|
+
function appBoundAddress(app: { httpServer: object | undefined }): string {
|
|
578
|
+
const native = app.httpServer;
|
|
579
|
+
assert.ok(native instanceof http.Server, "app exposes a node http.Server");
|
|
580
|
+
const addr = native.address();
|
|
581
|
+
assert.ok(addr && typeof addr === "object", "server has a bound AddressInfo");
|
|
582
|
+
return addr.address;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
test("defaults to a loopback bind when the manifest omits network (issue #235)", async () => {
|
|
586
|
+
const dir = await makeFixture();
|
|
587
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
588
|
+
const engine = new FakeEngine();
|
|
589
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
590
|
+
await app.start();
|
|
591
|
+
try {
|
|
592
|
+
assert.equal(appBoundAddress(app), "127.0.0.1");
|
|
593
|
+
} finally {
|
|
594
|
+
await app.stop();
|
|
595
|
+
await rm(dir, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('manifest network.bind:"all" binds every interface (issue #235)', async () => {
|
|
600
|
+
const dir = await makeFixture({ network: { bind: "all" } });
|
|
601
|
+
const logs: Array<{ level: string; msg: string }> = [];
|
|
602
|
+
const host = createNodeHost({ cwd: dir, log: (level, msg) => logs.push({ level, msg }) });
|
|
603
|
+
const engine = new FakeEngine();
|
|
604
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
605
|
+
await app.start();
|
|
606
|
+
try {
|
|
607
|
+
assert.equal(appBoundAddress(app), "0.0.0.0");
|
|
608
|
+
// exposing the app off-box must never be silent
|
|
609
|
+
assert.ok(
|
|
610
|
+
logs.some((l) => l.level === "warn" && l.msg.includes("all interfaces")),
|
|
611
|
+
"a warning is logged when binding to all interfaces",
|
|
612
|
+
);
|
|
613
|
+
} finally {
|
|
614
|
+
await app.stop();
|
|
615
|
+
await rm(dir, { recursive: true, force: true });
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("URBAN_BIND=all overrides a loopback manifest (issue #235)", async () => {
|
|
620
|
+
const dir = await makeFixture({ network: { bind: "loopback" } });
|
|
621
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
622
|
+
const engine = new FakeEngine();
|
|
623
|
+
const prev = process.env.URBAN_BIND;
|
|
624
|
+
process.env.URBAN_BIND = "all";
|
|
625
|
+
let app: Awaited<ReturnType<typeof createUrbanApp>> | undefined;
|
|
626
|
+
try {
|
|
627
|
+
app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
628
|
+
await app.start();
|
|
629
|
+
assert.equal(appBoundAddress(app), "0.0.0.0");
|
|
630
|
+
} finally {
|
|
631
|
+
if (prev === undefined) delete process.env.URBAN_BIND;
|
|
632
|
+
else process.env.URBAN_BIND = prev;
|
|
633
|
+
await app?.stop();
|
|
634
|
+
await rm(dir, { recursive: true, force: true });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
572
638
|
// A minimal deterministic scheduler seam: records armed timers so a test can assert the
|
|
573
639
|
// injected scheduler — not the live one — drives createUrbanApp's background loops.
|
|
574
640
|
function countingScheduler(): SchedulerDeps & { armed: () => number } {
|