@nanobpm/urban 0.48.0 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +80 -21
  2. package/dist/runtime/adapters/deno.js +22 -4
  3. package/dist/runtime/adapters/node.js +34 -4
  4. package/dist/runtime/core/context.d.ts +0 -4
  5. package/dist/runtime/core/glob.d.ts +9 -0
  6. package/dist/runtime/core/glob.js +21 -0
  7. package/dist/runtime/core/host.d.ts +82 -8
  8. package/dist/runtime/core/host.js +13 -0
  9. package/dist/runtime/core/manifest.d.ts +89 -2
  10. package/dist/runtime/core/manifest.js +72 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +9 -3
  12. package/dist/runtime/core/modules/deploy.js +69 -37
  13. package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
  14. package/dist/runtime/core/modules/instance-tracking.js +20 -5
  15. package/dist/runtime/core/modules/pages.js +34 -2
  16. package/dist/runtime/core/modules/surfaces.js +200 -19
  17. package/dist/runtime/core/router.d.ts +7 -0
  18. package/dist/runtime/core/router.js +9 -0
  19. package/dist/runtime/core/runtime.d.ts +0 -5
  20. package/dist/runtime/core/runtime.js +27 -4
  21. package/dist/runtime/core/validate.js +65 -1
  22. package/dist/runtime/engine/nanosdk.d.ts +10 -6
  23. package/dist/runtime/engine/nanosdk.js +72 -2
  24. package/dist/runtime/index.d.ts +3 -5
  25. package/dist/runtime/index.js +3 -4
  26. package/dist/runtime/run.js +0 -1
  27. package/dist/toolkit/fsio.js +9 -0
  28. package/dist/toolkit/gen.d.ts +26 -1
  29. package/dist/toolkit/gen.js +54 -6
  30. package/dist/toolkit/models.d.ts +6 -4
  31. package/dist/toolkit/models.js +7 -4
  32. package/package.json +1 -1
  33. package/src/runtime/adapters/deno.ts +26 -6
  34. package/src/runtime/adapters/node.test.ts +67 -0
  35. package/src/runtime/adapters/node.ts +37 -4
  36. package/src/runtime/core/context.ts +0 -4
  37. package/src/runtime/core/glob.ts +24 -0
  38. package/src/runtime/core/host.ts +86 -4
  39. package/src/runtime/core/manifest.test.ts +61 -1
  40. package/src/runtime/core/manifest.ts +136 -2
  41. package/src/runtime/core/modules/actions.test.ts +1 -0
  42. package/src/runtime/core/modules/api.test.ts +1 -0
  43. package/src/runtime/core/modules/cancel.test.ts +1 -0
  44. package/src/runtime/core/modules/connectors.test.ts +1 -0
  45. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  46. package/src/runtime/core/modules/deploy.test.ts +97 -161
  47. package/src/runtime/core/modules/deploy.ts +69 -37
  48. package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
  49. package/src/runtime/core/modules/instance-tracking.ts +19 -6
  50. package/src/runtime/core/modules/pages.test.ts +43 -0
  51. package/src/runtime/core/modules/pages.ts +34 -2
  52. package/src/runtime/core/modules/surfaces.test.ts +214 -5
  53. package/src/runtime/core/modules/surfaces.ts +199 -19
  54. package/src/runtime/core/modules/triggers.test.ts +1 -0
  55. package/src/runtime/core/modules/workers.test.ts +1 -0
  56. package/src/runtime/core/router.test.ts +10 -1
  57. package/src/runtime/core/router.ts +10 -0
  58. package/src/runtime/core/runtime.test.ts +67 -1
  59. package/src/runtime/core/runtime.ts +28 -9
  60. package/src/runtime/core/validate.test.ts +145 -0
  61. package/src/runtime/core/validate.ts +64 -1
  62. package/src/runtime/engine/nanosdk.test.ts +130 -1
  63. package/src/runtime/engine/nanosdk.ts +80 -3
  64. package/src/runtime/index.ts +11 -4
  65. package/src/runtime/run.ts +0 -1
  66. package/src/toolkit/fsio.ts +8 -0
  67. package/src/toolkit/gen.test.ts +46 -10
  68. package/src/toolkit/gen.ts +59 -6
  69. package/src/toolkit/models.test.ts +5 -5
  70. package/src/toolkit/models.ts +8 -5
  71. package/dist/runtime/core/modules/templates.d.ts +0 -18
  72. package/dist/runtime/core/modules/templates.js +0 -133
  73. package/src/runtime/core/modules/templates.ts +0 -153
package/README.md CHANGED
@@ -52,6 +52,38 @@ The engine address comes from `$CAMUNDA_REST_ADDRESS` (default
52
52
  serving to Falcon on a Nano server and falls back to REST elsewhere. Set it to
53
53
  `rest`, `falcon`, or `embedded` to pin a specific transport.
54
54
 
55
+ ### Network bind interface
56
+
57
+ By default an app's HTTP server binds to **loopback only** (`127.0.0.1`) — secure
58
+ by default, so surfaces, triggers and capability hooks are unreachable from other
59
+ machines. To make the app reachable from other hosts on the LAN (e.g. a
60
+ distributed worker fleet), opt in with the app-level `network.bind` manifest
61
+ setting:
62
+
63
+ ```jsonc
64
+ // nano.app.json
65
+ {
66
+ "network": { "bind": "loopback" } // default — 127.0.0.1, refuses off-box connections
67
+ // "network": { "bind": "all" } // 0.0.0.0 — reachable across the LAN
68
+ }
69
+ ```
70
+
71
+ | `network.bind` | Bind address | Reachability |
72
+ |---|---|---|
73
+ | `"loopback"` (default) | `127.0.0.1` | this machine only |
74
+ | `"all"` | `0.0.0.0` | every interface / the LAN |
75
+
76
+ Ops can override the manifest at deploy time with the `URBAN_BIND` environment
77
+ variable (`URBAN_BIND=all` or `URBAN_BIND=loopback`); a valid value wins over the
78
+ manifest, the manifest stays the declarative default. Binding to all interfaces
79
+ emits a startup `warn` log because it exposes the app off-box.
80
+
81
+ > **Security:** binding to all interfaces exposes the app's token-gated capability
82
+ > hooks on the LAN. Any LOCAL-mode "well-known localhost" credential (e.g. the
83
+ > agentic channel's LOCAL mode) must **not** be served on a non-loopback bind — a
84
+ > consumer that mints one must gate it on a loopback bind.
85
+
86
+
55
87
  ### A typical session
56
88
 
57
89
  ```bash
@@ -178,40 +210,67 @@ wiring; `WorkflowClient` deploys and starts, `Worker` hosts your `run` steps.
178
210
  inspectable in a modeller/Operate — `@nanobpm/urban` bundles `bpmn-auto-layout`
179
211
  so this works out of the box.
180
212
 
181
- ### Deploy-time model templates (`{{name}}`)
213
+ ### Deploy by convention (`resources/`)
214
+
215
+ Deployables are discovered by convention: with **no `models`** block in the
216
+ manifest, `@nanobpm/urban` deploys everything under `resources/` — walked
217
+ **shallow, one level deep** (`resources/*` and `resources/<subdir>/*`, never
218
+ deeper). Content type is by extension: `.bpmn`/`.dmn` → BPMN/DMN, `.form` →
219
+ form-js form, anything else → a `GenericScript` resource (this is how agent
220
+ prompts are deployed — see below).
221
+
222
+ ```
223
+ resources/
224
+ processes/ order.bpmn ← deployed as a BPMN process
225
+ decisions/ route.dmn ← deployed as a DMN decision
226
+ forms/ approve.form ← deployed as a form
227
+ prompts/ review.md ← deployed as a GenericScript (an agent prompt)
228
+ ```
229
+
230
+ `resources/` is **deploy-only**: everything under it deploys, and nothing outside
231
+ it ever does — so docs (`docs/`, `AGENTS.md`, top-level `*.md`) live **outside**
232
+ `resources/` and are never swept into a deployment. Resources are keyed by their
233
+ **basename** (filename), so two files with the same name in different sub-dirs are
234
+ a **hard error** — rename or relocate one. `urban gen` follows the same
235
+ convention: with no `models`, its `nano:shape`/code-first model scan defaults to a
236
+ **shallow** `resources/` walk — `.bpmn`/`.dmn` directly under `resources/` and one
237
+ level down in `resources/<subdir>/`, never deeper — and derived models are written to
238
+ `resources/processes/`, exactly where the convention deploy then finds them.
182
239
 
183
- A model file can carry `{{name}}` placeholders that are substituted at deploy
184
- time — handy for inlining a large asset (e.g. an agent prompt authored as its own
185
- file) into a `zeebe:header` value instead of hand-pasting it into XML or shipping
186
- it as a bulky per-instance process variable. Declare the template sources under
187
- `models.templates` (globs, a bare directory that is scanned, or literal files); a
188
- template's name is its file **stem**, so `prompts/review.md` fills `{{review}}`:
240
+ To opt out of the convention, declare `models` globs — they are used verbatim and
241
+ the `resources/` walk is skipped:
189
242
 
190
243
  ```jsonc
191
244
  {
192
245
  "models": {
193
- "processes": ["processes/*.bpmn"],
194
- "templates": ["prompts/*.md"]
246
+ "processes": ["src/models/*.bpmn"],
247
+ "forms": ["src/forms/*.form"]
195
248
  }
196
249
  }
197
250
  ```
198
251
 
252
+ ### Modular prompts — the blessed path (`zeebe:linkedResource`)
253
+
254
+ To keep a large agent prompt out of a model's XML, author it as its own file under
255
+ `resources/prompts/` (deployed as a `GenericScript`) and **link** it into the
256
+ service task with a `zeebe:linkedResource` bound to the latest deployed version;
257
+ the runtime resolves it with the `appendPrompt` FEEL helper at execution time:
258
+
199
259
  ```xml
200
- <!-- processes/agent.bpmn -->
201
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review}}" />
260
+ <!-- resources/processes/agent.bpmn -->
261
+ <zeebe:linkedResource linkName="prompt" resourceType="GenericScript"
262
+ bindingType="latest" resourceId="review" />
202
263
  ```
203
264
 
204
- Content is escaped for the resource's type: XML for `.bpmn`/`.dmn` (newlines/tabs
205
- become character references so a multi-line prompt survives XML attribute-value
206
- normalization), JSON for `.form`. Substitution is single-pass (a template's own
207
- `{{…}}` is not re-expanded); an unknown placeholder is left verbatim and logged as
208
- a warning. Templates can also be supplied programmatically via the `templates`
209
- run option (globs or an explicit `name → content` map), which wins over the
210
- manifest on a name collision:
265
+ This is the **only** supported prompt-modularity mechanism — there is no
266
+ deploy-time string substitution (`{{name}}` templating has been removed).
211
267
 
212
- ```ts
213
- await runFromEnv({ templates: { review: await readPrompt("review.md") } });
214
- ```
268
+ > **Value-injection caveat.** Injecting a *value* into a model at deploy time (a
269
+ > URL, a feature flag, an environment-specific constant) must use **runtime
270
+ > variables / FEEL**, never string substitution baked into the model. A model is
271
+ > deployed once and shared across environments; bake a value in and you fork the
272
+ > model per environment. Pass the value as a process variable and reference it
273
+ > with FEEL instead.
215
274
 
216
275
  ### Triggers — the inbound I/O edge
217
276
 
@@ -16,6 +16,7 @@ import { existsSync } from "node:fs";
16
16
  import { DatabaseSync } from "node:sqlite";
17
17
  import { AsyncLocalStorage } from "node:async_hooks";
18
18
  import { resolveModulePath } from "../core/module-path.js";
19
+ import { LOOPBACK_HOST } from "../core/manifest.js";
19
20
  import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.js";
20
21
  function sqliteParams(params) {
21
22
  return params.map((param) => {
@@ -71,6 +72,19 @@ export function createDenoHost(opts = {}) {
71
72
  return [];
72
73
  }
73
74
  },
75
+ async listSubdirs(dir) {
76
+ try {
77
+ const names = [];
78
+ for await (const e of Deno.readDir(abs(dir))) {
79
+ if (e.isDirectory)
80
+ names.push(e.name);
81
+ }
82
+ return names;
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ },
74
88
  async exists(p) {
75
89
  try {
76
90
  await Deno.stat(abs(p));
@@ -90,8 +104,8 @@ export function createDenoHost(opts = {}) {
90
104
  const mod = import(__rewriteRelativeImportExtension(href));
91
105
  return mod;
92
106
  },
93
- async serveHttp(port, handler) {
94
- return startDenoServer(port, handler);
107
+ async serveHttp(port, handler, bindHost) {
108
+ return startDenoServer(port, handler, bindHost);
95
109
  },
96
110
  watch(onChange) {
97
111
  const w = Deno.watchFs(cwd, { recursive: true });
@@ -146,9 +160,13 @@ function wrapSqlite(db) {
146
160
  close: () => db.close(),
147
161
  };
148
162
  }
149
- function startDenoServer(port, handler) {
163
+ function startDenoServer(port, handler, bindHost) {
150
164
  return new Promise((resolveServer) => {
151
- const server = Deno.serve({ port, onListen: ({ port: p }) => {
165
+ // `hostname` binds the interface (issue #235): `"127.0.0.1"` loopback-only, `"0.0.0.0"`
166
+ // all interfaces. When no bind host is resolved we fail *closed* to loopback rather than
167
+ // inheriting Deno's default, so a caller that omits it can never silently expose the server
168
+ // off-box.
169
+ const server = Deno.serve({ port, hostname: bindHost ?? LOOPBACK_HOST, onListen: ({ port: p }) => {
152
170
  resolveServer({
153
171
  port: p,
154
172
  stop: () => server.shutdown(),
@@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { DatabaseSync } from "node:sqlite";
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
20
  import { resolveModulePath } from "../core/module-path.js";
21
+ import { LOOPBACK_HOST } from "../core/manifest.js";
21
22
  import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.js";
22
23
  /**
23
24
  * The default host log sink: NDJSON to stdout (`debug`/`info`) and stderr (`warn`/`error`), gated by
@@ -114,6 +115,15 @@ export function createNodeHost(opts = {}) {
114
115
  return [];
115
116
  }
116
117
  },
118
+ async listSubdirs(dir) {
119
+ try {
120
+ const entries = await readdir(abs(dir), { withFileTypes: true });
121
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ },
117
127
  async exists(p) {
118
128
  try {
119
129
  await stat(abs(p));
@@ -142,8 +152,8 @@ export function createNodeHost(opts = {}) {
142
152
  const href = pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
143
153
  await import(__rewriteRelativeImportExtension(href));
144
154
  },
145
- async serveHttp(port, handler) {
146
- return await startNodeServer(port, handler);
155
+ async serveHttp(port, handler, bindHost) {
156
+ return await startNodeServer(port, handler, bindHost);
147
157
  },
148
158
  watch(onChange) {
149
159
  const onFsEvent = (_event, filename) => {
@@ -192,7 +202,7 @@ function wrapNodeSqlite(db) {
192
202
  close: () => db.close(),
193
203
  };
194
204
  }
195
- async function startNodeServer(port, handler) {
205
+ async function startNodeServer(port, handler, bindHost) {
196
206
  const server = createServer(async (nreq, nres) => {
197
207
  const chunks = [];
198
208
  for await (const c of nreq) {
@@ -245,7 +255,27 @@ async function startNodeServer(port, handler) {
245
255
  sockets.add(socket);
246
256
  socket.once("close", () => sockets.delete(socket));
247
257
  });
248
- await new Promise((res) => server.listen(port, res));
258
+ // Bind to the requested interface (issue #235): `"127.0.0.1"` keeps the app loopback-only
259
+ // (secure default), `"0.0.0.0"` exposes it on the LAN. When no bind host is resolved we fail
260
+ // *closed* to loopback rather than inheriting Node's bind-all default, so a caller that omits
261
+ // it can never silently expose the server off-box.
262
+ const listenHost = bindHost ?? LOOPBACK_HOST;
263
+ // `server.listen` reports bind failures (EADDRINUSE/EACCES/…) via the `error` event, not the
264
+ // listening callback. Reject on the first such error so callers see the failure instead of
265
+ // hanging forever waiting for a `listening` that will never fire.
266
+ await new Promise((res, rej) => {
267
+ const onError = (err) => {
268
+ server.removeListener("listening", onListening);
269
+ rej(err);
270
+ };
271
+ const onListening = () => {
272
+ server.removeListener("error", onError);
273
+ res();
274
+ };
275
+ server.once("error", onError);
276
+ server.once("listening", onListening);
277
+ server.listen(port, listenHost);
278
+ });
249
279
  const addr = server.address();
250
280
  const actualPort = typeof addr === "object" && addr ? addr.port : port;
251
281
  return {
@@ -1,7 +1,6 @@
1
1
  import type { AppManifest } from "./manifest.ts";
2
2
  import type { EngineClient, HostContext } from "./host.ts";
3
3
  import type { EngineSdkClient } from "../engine/sdk.ts";
4
- import type { TemplateSource } from "./modules/templates.ts";
5
4
  /** Everything a runtime module needs. Passed to each module's mount function. */
6
5
  export interface RuntimeContext {
7
6
  manifest: AppManifest;
@@ -9,9 +8,6 @@ export interface RuntimeContext {
9
8
  engine: EngineClient;
10
9
  /** App root directory (paths in the manifest are relative to this). */
11
10
  root: string;
12
- /** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
13
- * `name → content` map), merged over the manifest's `models.templates`. See `deployModels`. */
14
- templates?: TemplateSource;
15
11
  }
16
12
  /** A mounted module returns a disposer so the runtime can tear it down cleanly. */
17
13
  export interface Mounted {
@@ -3,3 +3,12 @@ import type { HostContext } from "./host.ts";
3
3
  export declare function expandPattern(host: HostContext, root: string, pattern: string): Promise<string[]>;
4
4
  /** Expand many patterns, de-duplicating while preserving first-seen order. */
5
5
  export declare function expandPatterns(host: HostContext, root: string, patterns: string[] | undefined): Promise<string[]>;
6
+ /**
7
+ * Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
8
+ * the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
9
+ * never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
10
+ * deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
11
+ * descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
12
+ * top-level files. Returns root-prefixed paths, sorted for determinism.
13
+ */
14
+ export declare function discoverResources(host: HostContext, root: string, dir: string): Promise<string[]>;
@@ -49,3 +49,24 @@ export async function expandPatterns(host, root, patterns) {
49
49
  }
50
50
  return out;
51
51
  }
52
+ /**
53
+ * Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
54
+ * the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
55
+ * never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
56
+ * deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
57
+ * descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
58
+ * top-level files. Returns root-prefixed paths, sorted for determinism.
59
+ */
60
+ export async function discoverResources(host, root, dir) {
61
+ const base = joinPath(root, dir);
62
+ const out = [];
63
+ for (const name of await host.listDir(base))
64
+ out.push(joinPath(base, name));
65
+ const subdirs = host.listSubdirs ? await host.listSubdirs(base) : [];
66
+ for (const sub of subdirs.slice().sort()) {
67
+ const subBase = joinPath(base, sub);
68
+ for (const name of await host.listDir(subBase))
69
+ out.push(joinPath(subBase, name));
70
+ }
71
+ return out.sort();
72
+ }
@@ -71,6 +71,12 @@ export interface HostContext {
71
71
  readTextFile(path: string): Promise<string>;
72
72
  /** List file names (not directories) directly under `dir`. Returns [] if missing. */
73
73
  listDir(dir: string): Promise<string[]>;
74
+ /**
75
+ * List sub-directory names (not files) directly under `dir`. Returns [] if missing. Optional:
76
+ * the deploy-by-convention walk uses it to descend one level into `resources/<subdir>/` (a host
77
+ * that can't enumerate directories then deploys only the files directly under `resources/`).
78
+ */
79
+ listSubdirs?(dir: string): Promise<string[]>;
74
80
  /** True if the path exists (file or directory). */
75
81
  exists(path: string): Promise<boolean>;
76
82
  /** Open (creating if needed) a SQLite database at a filesystem path. */
@@ -90,8 +96,16 @@ export interface HostContext {
90
96
  * connector mount then reports the workers as unsupported rather than crashing.
91
97
  */
92
98
  importConnectorModule?(entry: string): Promise<void>;
93
- /** Start an HTTP server. Routing is done by the caller inside `handler`. */
94
- serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
99
+ /**
100
+ * Start an HTTP server. Routing is done by the caller inside `handler`. `bindHost` is the
101
+ * network interface to bind (e.g. `"127.0.0.1"` for loopback, `"0.0.0.0"` for all interfaces);
102
+ * the runtime always resolves it from the manifest `network.bind` setting (issue #235,
103
+ * loopback by default). It stays optional so hosts that can't bind-control still satisfy the
104
+ * contract, but the built-in node/deno adapters fail *closed* to loopback when it is omitted —
105
+ * they never inherit a bind-all default — so the secure-by-default guarantee can't silently
106
+ * regress.
107
+ */
108
+ serveHttp(port: number, handler: HttpHandler, bindHost?: string): Promise<HttpServer>;
95
109
  /**
96
110
  * Recursively watch the app root for file changes, invoking `onChange` with the
97
111
  * changed path (app-root-relative when the runtime reports it that way). Optional:
@@ -150,6 +164,16 @@ export declare function isBpmnError(err: unknown): err is {
150
164
  errorCode: string;
151
165
  message?: string;
152
166
  };
167
+ /**
168
+ * The canonical presence rule for a form identifier ({@link EngineClient.getForm}): an
169
+ * empty or whitespace-only value is treated as *absent*. Returns the *trimmed* value when
170
+ * present (so a padded `" form-123 "` resolves against the space-free deployed key),
171
+ * otherwise `undefined`. This is the single source of truth shared by `getForm`'s
172
+ * resolution gate ({@link EngineClient.getForm} adapters) and the `taskInbox`
173
+ * `/api/form` route's presence check, so the two cannot drift on what counts as "an
174
+ * identifier was provided" (e.g. a whitespace-only `?formKey= ` is absent in both).
175
+ */
176
+ export declare function presentFormIdentifier(value: string | undefined): string | undefined;
153
177
  /** A registered worker subscription. */
154
178
  export interface WorkerSubscription {
155
179
  readonly jobType: string;
@@ -163,11 +187,47 @@ export interface WorkerSubscription {
163
187
  * element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
164
188
  */
165
189
  export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
190
+ /**
191
+ * A user task's lifecycle state, as the engine reports it on a `/v2/user-tasks/search`
192
+ * result. `CREATED` is the only *open* (answerable) state — a task in any other state has
193
+ * already been answered (`COMPLETED`), withdrawn (`CANCELED`), or errored (`FAILED`) and must
194
+ * never surface in the `taskInbox` as if it were actionable.
195
+ */
196
+ export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
166
197
  /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
167
198
  export interface ProcessInstanceSnapshot {
168
199
  readonly processInstanceKey: string;
169
200
  readonly state: ProcessInstanceState;
170
201
  }
202
+ /**
203
+ * A single open user task as {@link EngineClient.searchUserTasks} reports it. Carries the
204
+ * task's identity plus its resolved form linkage so the `taskInbox` surface (ADR 0026) can
205
+ * fetch and render the linked `.form`. `formKey` is the engine's resolution of a
206
+ * `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form at task-creation
207
+ * time; `externalFormReference` is a form linked outside the deployment. Both are absent for a
208
+ * task with no linked form (the surface then falls back to the raw key-list + complete path).
209
+ */
210
+ export interface UserTaskSummary {
211
+ readonly userTaskKey: string;
212
+ readonly elementId?: string;
213
+ readonly variables?: Record<string, unknown>;
214
+ /** The resolved key of the linked deployed form, if the task has one. */
215
+ readonly formKey?: string;
216
+ /** An external form reference (a form linked outside the deployment), if any. */
217
+ readonly externalFormReference?: string;
218
+ }
219
+ /**
220
+ * A deployed form's form-js schema plus its identifying metadata, as
221
+ * {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
222
+ * (`{ type: "default", schemaVersion, components: [...] }`) that the surface renders
223
+ * client-side; `formKey`/`formId`/`version` identify the exact deployed form resolved.
224
+ */
225
+ export interface FormSchema {
226
+ readonly formKey?: string;
227
+ readonly formId?: string;
228
+ readonly version?: number;
229
+ readonly schema: Record<string, unknown>;
230
+ }
171
231
  /**
172
232
  * EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
173
233
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
@@ -200,16 +260,30 @@ export interface EngineClient {
200
260
  correlationKey?: string;
201
261
  variables?: Record<string, unknown>;
202
262
  }): Promise<void>;
203
- /** Search open user tasks (optionally by process instance). */
263
+ /** Search user tasks (optionally by process instance, assignee, or candidate group).
264
+ * By default this is unfiltered by lifecycle state and may return tasks in any state
265
+ * (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
266
+ * (answerable) tasks. Each result carries the task's resolved form linkage
267
+ * (`formKey`/`externalFormReference`) when present. */
204
268
  searchUserTasks(filter?: {
205
269
  processInstanceKey?: string;
206
270
  assignee?: string;
207
271
  candidateGroup?: string;
208
- }): Promise<{
209
- userTaskKey: string;
210
- elementId?: string;
211
- variables?: Record<string, unknown>;
212
- }[]>;
272
+ state?: UserTaskState;
273
+ }): Promise<UserTaskSummary[]>;
274
+ /**
275
+ * Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
276
+ * `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
277
+ * fallback, by `formId`. An empty or whitespace-only identifier is treated as absent,
278
+ * so a blank `formKey` falls through to `formId`. How `formId` is addressed is
279
+ * adapter-specific: an adapter may pass it straight through as the lookup key, or
280
+ * resolve it to the latest deployed form's key. Returns `null` when no matching form
281
+ * exists — the caller then falls back to the no-form path.
282
+ */
283
+ getForm(input: {
284
+ formKey?: string;
285
+ formId?: string;
286
+ }): Promise<FormSchema | null>;
213
287
  /** Complete a user task. */
214
288
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
215
289
  /**
@@ -29,3 +29,16 @@ export function isBpmnError(err) {
29
29
  return (typeof errorCode === "string" &&
30
30
  errorCode.length > 0);
31
31
  }
32
+ /**
33
+ * The canonical presence rule for a form identifier ({@link EngineClient.getForm}): an
34
+ * empty or whitespace-only value is treated as *absent*. Returns the *trimmed* value when
35
+ * present (so a padded `" form-123 "` resolves against the space-free deployed key),
36
+ * otherwise `undefined`. This is the single source of truth shared by `getForm`'s
37
+ * resolution gate ({@link EngineClient.getForm} adapters) and the `taskInbox`
38
+ * `/api/form` route's presence check, so the two cannot drift on what counts as "an
39
+ * identifier was provided" (e.g. a whitespace-only `?formKey= ` is absent in both).
40
+ */
41
+ export function presentFormIdentifier(value) {
42
+ const trimmed = value?.trim();
43
+ return trimmed ? trimmed : undefined;
44
+ }
@@ -1,8 +1,95 @@
1
1
  import type { HostContext } from "./host.ts";
2
- import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
3
- export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
2
+ import type { AppManifest, InstanceTracking as SchemaInstanceTracking, Worker } from "@nanobpm/nano-app-schema";
3
+ export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
4
+ /**
5
+ * `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector.
6
+ *
7
+ * `terminalStatuses` is landing in the canonical schema (`@nanobpm/nano-app-schema`,
8
+ * Magikcraft/nano-bpm#769): an exclusion list — the runtime polls every row whose
9
+ * `statusField` is NOT in it — so a newly-added non-terminal status is reconciled by
10
+ * default instead of silently dropping out of an allow-list. Until that publishes and this
11
+ * package's dep is bumped, extend the schema type here so the reconciler can consume the
12
+ * field. Drop this augmentation and re-export `InstanceTracking` from the schema once the
13
+ * dep bump lands (No Drift Surfaces).
14
+ */
15
+ export type InstanceTracking = SchemaInstanceTracking & {
16
+ /** Values of `statusField` considered finished. When set, every row NOT in one of these
17
+ * is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
18
+ readonly terminalStatuses?: readonly string[];
19
+ };
20
+ /**
21
+ * A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
22
+ * a non-empty array of non-empty strings. Both the reconciler's row selection and the manifest
23
+ * validator gate on this one predicate, so "is this selector active?" has a single definition and
24
+ * the two can't drift (No Drift Surfaces). Anything else — `undefined`, an empty array, a bare
25
+ * string, or an array holding a non-string/empty entry — is treated as *not configured*: the
26
+ * reconciler falls through to the fail-open poll-all path and the validator flags the malformed
27
+ * shape at author time (rather than letting e.g. `new Set("abandoned")` silently become a set of
28
+ * characters, or `activeStatuses.map(...)` crash on a non-array).
29
+ */
30
+ export declare function isConfiguredStatusSelector(v: unknown): v is readonly string[];
4
31
  /** The job type a worker subscribes to (schema key: `taskType`). */
5
32
  export declare function workerJobType(w: Worker): string | undefined;
33
+ /**
34
+ * The valid HTTP bind modes, in declaration order. This is the single runtime source of
35
+ * truth for the set: {@link BindMode}, {@link isBindMode}, and the manifest validator
36
+ * (`collectManifestIssues`) all derive from it, so the allowed values live in one place.
37
+ */
38
+ export declare const BIND_MODES: readonly ["loopback", "all"];
39
+ /**
40
+ * Which interface the app's embedded HTTP server binds to (issue #235):
41
+ * - `"loopback"` (default): `127.0.0.1` — secure-by-default; refuses off-box connections.
42
+ * - `"all"`: `0.0.0.0` — reachable from other hosts on the LAN (distributed worker fleet).
43
+ */
44
+ export type BindMode = (typeof BIND_MODES)[number];
45
+ /**
46
+ * App-level network settings (issue #235). Absent ⇒ loopback.
47
+ *
48
+ * Mirrored locally — like the `api` binding (ADR 0058) — until `@nanobpm/nano-app-schema`
49
+ * folds `network` into `AppManifest` (the schema PR lands first). The manifest shape is
50
+ * owned by that package (ADR 0027), so we deliberately do NOT augment `AppManifest` via
51
+ * `declare module` (CI bans it): the field is threaded through the runtime-side helpers
52
+ * that take a `{ network?: NetworkConfig }` view and is read off the raw manifest object.
53
+ * Once the schema ships `network`, this local type (and the allow-list entry in
54
+ * validate.ts) can be deleted and the field flows from the schema like every other block.
55
+ */
56
+ export interface NetworkConfig {
57
+ /** HTTP bind interface. Default `"loopback"`. */
58
+ bind?: BindMode;
59
+ }
60
+ /**
61
+ * The keys the runtime-side `network` block understands, as the single source of truth for
62
+ * the validator's `additionalProperties: false` check under `network`. Typed as
63
+ * `(keyof NetworkConfig)[]` so it can only ever list real {@link NetworkConfig} keys — a
64
+ * drift test (`manifest.test.ts`) asserts it stays complete as the block grows.
65
+ */
66
+ export declare const NETWORK_KEYS: readonly (keyof NetworkConfig)[];
67
+ /** A runtime-side view of a manifest carrying the pending-schema `network` block. */
68
+ export interface WithNetwork {
69
+ network?: NetworkConfig;
70
+ }
71
+ /** Loopback bind address — secure/local default. */
72
+ export declare const LOOPBACK_HOST = "127.0.0.1";
73
+ /** All-interfaces IPv4 bind address — LAN / distributed fleet. */
74
+ export declare const ALL_INTERFACES_HOST = "0.0.0.0";
75
+ /** Environment variable that overrides the manifest bind mode for ops. */
76
+ export declare const BIND_ENV_VAR = "URBAN_BIND";
77
+ /** True when `v` is a valid {@link BindMode}. */
78
+ export declare function isBindMode(v: unknown): v is BindMode;
79
+ /**
80
+ * Resolve the effective HTTP bind mode: the `URBAN_BIND` env override wins when set to a
81
+ * valid value, else the manifest's `network.bind`, else `"loopback"` (secure by default).
82
+ * An env value that is present but invalid is ignored (falls through to the manifest/default);
83
+ * callers that want to surface that can check {@link isBindMode} on the raw env value.
84
+ */
85
+ export declare function resolveBindMode(manifest: AppManifest | WithNetwork, lookup?: (name: string) => string | undefined): BindMode;
86
+ /** Map a {@link BindMode} to the concrete host address passed to the HTTP adapter. */
87
+ export declare function bindModeToHost(mode: BindMode): string;
88
+ /**
89
+ * Resolve the concrete bind host address from the manifest + environment.
90
+ * `"loopback"` ⇒ `127.0.0.1` (default), `"all"` ⇒ `0.0.0.0`.
91
+ */
92
+ export declare function resolveBindHost(manifest: AppManifest | WithNetwork, lookup?: (name: string) => string | undefined): string;
6
93
  /**
7
94
  * Expand `${VAR}` and `${VAR:-default}` placeholders in a string using `lookup`.
8
95
  * Unset vars with no default expand to "" (matching common shell semantics).
@@ -7,10 +7,82 @@
7
7
  // bundle) and adds the runtime-side helpers the App loader needs. No shape is
8
8
  // hand-mirrored here — that eliminated the schema/type drift the vendored copies
9
9
  // used to cause.
10
+ import { isRecord } from "./guards.js";
11
+ /**
12
+ * A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
13
+ * a non-empty array of non-empty strings. Both the reconciler's row selection and the manifest
14
+ * validator gate on this one predicate, so "is this selector active?" has a single definition and
15
+ * the two can't drift (No Drift Surfaces). Anything else — `undefined`, an empty array, a bare
16
+ * string, or an array holding a non-string/empty entry — is treated as *not configured*: the
17
+ * reconciler falls through to the fail-open poll-all path and the validator flags the malformed
18
+ * shape at author time (rather than letting e.g. `new Set("abandoned")` silently become a set of
19
+ * characters, or `activeStatuses.map(...)` crash on a non-array).
20
+ */
21
+ export function isConfiguredStatusSelector(v) {
22
+ return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === "string" && s.length > 0);
23
+ }
10
24
  /** The job type a worker subscribes to (schema key: `taskType`). */
11
25
  export function workerJobType(w) {
12
26
  return w.taskType;
13
27
  }
28
+ /**
29
+ * The valid HTTP bind modes, in declaration order. This is the single runtime source of
30
+ * truth for the set: {@link BindMode}, {@link isBindMode}, and the manifest validator
31
+ * (`collectManifestIssues`) all derive from it, so the allowed values live in one place.
32
+ */
33
+ export const BIND_MODES = ["loopback", "all"];
34
+ /**
35
+ * The keys the runtime-side `network` block understands, as the single source of truth for
36
+ * the validator's `additionalProperties: false` check under `network`. Typed as
37
+ * `(keyof NetworkConfig)[]` so it can only ever list real {@link NetworkConfig} keys — a
38
+ * drift test (`manifest.test.ts`) asserts it stays complete as the block grows.
39
+ */
40
+ export const NETWORK_KEYS = ["bind"];
41
+ /** Loopback bind address — secure/local default. */
42
+ export const LOOPBACK_HOST = "127.0.0.1";
43
+ /** All-interfaces IPv4 bind address — LAN / distributed fleet. */
44
+ export const ALL_INTERFACES_HOST = "0.0.0.0";
45
+ /** Environment variable that overrides the manifest bind mode for ops. */
46
+ export const BIND_ENV_VAR = "URBAN_BIND";
47
+ /** True when `v` is a valid {@link BindMode}. */
48
+ export function isBindMode(v) {
49
+ return BIND_MODES.some((mode) => mode === v);
50
+ }
51
+ /**
52
+ * Read the pending-schema `network.bind` off a raw manifest object. Because `network` is
53
+ * mirrored locally (not yet in `AppManifest`'s static shape), it is read reflectively —
54
+ * the same way the `api` binding is threaded (ADR 0058) — and validated with
55
+ * {@link isBindMode} so this always returns a real {@link BindMode} or `undefined`.
56
+ */
57
+ function manifestBind(manifest) {
58
+ const network = Reflect.get(manifest, "network");
59
+ if (!isRecord(network))
60
+ return undefined;
61
+ return isBindMode(network.bind) ? network.bind : undefined;
62
+ }
63
+ /**
64
+ * Resolve the effective HTTP bind mode: the `URBAN_BIND` env override wins when set to a
65
+ * valid value, else the manifest's `network.bind`, else `"loopback"` (secure by default).
66
+ * An env value that is present but invalid is ignored (falls through to the manifest/default);
67
+ * callers that want to surface that can check {@link isBindMode} on the raw env value.
68
+ */
69
+ export function resolveBindMode(manifest, lookup = () => undefined) {
70
+ const envRaw = lookup(BIND_ENV_VAR);
71
+ if (isBindMode(envRaw))
72
+ return envRaw;
73
+ return manifestBind(manifest) ?? "loopback";
74
+ }
75
+ /** Map a {@link BindMode} to the concrete host address passed to the HTTP adapter. */
76
+ export function bindModeToHost(mode) {
77
+ return mode === "all" ? ALL_INTERFACES_HOST : LOOPBACK_HOST;
78
+ }
79
+ /**
80
+ * Resolve the concrete bind host address from the manifest + environment.
81
+ * `"loopback"` ⇒ `127.0.0.1` (default), `"all"` ⇒ `0.0.0.0`.
82
+ */
83
+ export function resolveBindHost(manifest, lookup = () => undefined) {
84
+ return bindModeToHost(resolveBindMode(manifest, lookup));
85
+ }
14
86
  const ENV_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
15
87
  /**
16
88
  * Expand `${VAR}` and `${VAR:-default}` placeholders in a string using `lookup`.