@nanobpm/urban 0.48.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 +34 -2
- 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/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +54 -6
- 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 +43 -0
- package/src/runtime/core/modules/pages.ts +34 -2
- 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/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +46 -10
- package/src/toolkit/gen.ts +59 -6
- 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
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
WatchHandle,
|
|
22
22
|
} from "../core/host.ts";
|
|
23
23
|
import { resolveModulePath } from "../core/module-path.ts";
|
|
24
|
+
import { LOOPBACK_HOST } from "../core/manifest.ts";
|
|
24
25
|
import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.ts";
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -137,6 +138,14 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
137
138
|
return [];
|
|
138
139
|
}
|
|
139
140
|
},
|
|
141
|
+
async listSubdirs(dir) {
|
|
142
|
+
try {
|
|
143
|
+
const entries = await readdir(abs(dir), { withFileTypes: true });
|
|
144
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
},
|
|
140
149
|
async exists(p) {
|
|
141
150
|
try {
|
|
142
151
|
await stat(abs(p));
|
|
@@ -166,8 +175,8 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
166
175
|
pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
167
176
|
await import(href);
|
|
168
177
|
},
|
|
169
|
-
async serveHttp(port, handler) {
|
|
170
|
-
return await startNodeServer(port, handler);
|
|
178
|
+
async serveHttp(port, handler, bindHost) {
|
|
179
|
+
return await startNodeServer(port, handler, bindHost);
|
|
171
180
|
},
|
|
172
181
|
watch(onChange) {
|
|
173
182
|
const onFsEvent = (_event: unknown, filename: string | Buffer | null) => {
|
|
@@ -216,7 +225,11 @@ function wrapNodeSqlite(db: DatabaseSync): SqliteDb {
|
|
|
216
225
|
};
|
|
217
226
|
}
|
|
218
227
|
|
|
219
|
-
async function startNodeServer(
|
|
228
|
+
async function startNodeServer(
|
|
229
|
+
port: number,
|
|
230
|
+
handler: HttpHandler,
|
|
231
|
+
bindHost?: string,
|
|
232
|
+
): Promise<HttpServer> {
|
|
220
233
|
const server = createServer(async (nreq, nres) => {
|
|
221
234
|
const chunks: Buffer[] = [];
|
|
222
235
|
for await (const c of nreq) {
|
|
@@ -263,7 +276,27 @@ async function startNodeServer(port: number, handler: HttpHandler): Promise<Http
|
|
|
263
276
|
sockets.add(socket);
|
|
264
277
|
socket.once("close", () => sockets.delete(socket));
|
|
265
278
|
});
|
|
266
|
-
|
|
279
|
+
// Bind to the requested interface (issue #235): `"127.0.0.1"` keeps the app loopback-only
|
|
280
|
+
// (secure default), `"0.0.0.0"` exposes it on the LAN. When no bind host is resolved we fail
|
|
281
|
+
// *closed* to loopback rather than inheriting Node's bind-all default, so a caller that omits
|
|
282
|
+
// it can never silently expose the server off-box.
|
|
283
|
+
const listenHost = bindHost ?? LOOPBACK_HOST;
|
|
284
|
+
// `server.listen` reports bind failures (EADDRINUSE/EACCES/…) via the `error` event, not the
|
|
285
|
+
// listening callback. Reject on the first such error so callers see the failure instead of
|
|
286
|
+
// hanging forever waiting for a `listening` that will never fire.
|
|
287
|
+
await new Promise<void>((res, rej) => {
|
|
288
|
+
const onError = (err: Error) => {
|
|
289
|
+
server.removeListener("listening", onListening);
|
|
290
|
+
rej(err);
|
|
291
|
+
};
|
|
292
|
+
const onListening = () => {
|
|
293
|
+
server.removeListener("error", onError);
|
|
294
|
+
res();
|
|
295
|
+
};
|
|
296
|
+
server.once("error", onError);
|
|
297
|
+
server.once("listening", onListening);
|
|
298
|
+
server.listen(port, listenHost);
|
|
299
|
+
});
|
|
267
300
|
const addr = server.address();
|
|
268
301
|
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
269
302
|
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
|
|
|
6
5
|
/** Everything a runtime module needs. Passed to each module's mount function. */
|
|
7
6
|
export interface RuntimeContext {
|
|
@@ -10,9 +9,6 @@ export interface RuntimeContext {
|
|
|
10
9
|
engine: EngineClient;
|
|
11
10
|
/** App root directory (paths in the manifest are relative to this). */
|
|
12
11
|
root: string;
|
|
13
|
-
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
14
|
-
* `name → content` map), merged over the manifest's `models.templates`. See `deployModels`. */
|
|
15
|
-
templates?: TemplateSource;
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
/** A mounted module returns a disposer so the runtime can tear it down cleanly. */
|
package/src/runtime/core/glob.ts
CHANGED
|
@@ -61,3 +61,27 @@ export async function expandPatterns(
|
|
|
61
61
|
}
|
|
62
62
|
return out;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
|
|
67
|
+
* the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
|
|
68
|
+
* never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
|
|
69
|
+
* deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
|
|
70
|
+
* descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
|
|
71
|
+
* top-level files. Returns root-prefixed paths, sorted for determinism.
|
|
72
|
+
*/
|
|
73
|
+
export async function discoverResources(
|
|
74
|
+
host: HostContext,
|
|
75
|
+
root: string,
|
|
76
|
+
dir: string,
|
|
77
|
+
): Promise<string[]> {
|
|
78
|
+
const base = joinPath(root, dir);
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
for (const name of await host.listDir(base)) out.push(joinPath(base, name));
|
|
81
|
+
const subdirs = host.listSubdirs ? await host.listSubdirs(base) : [];
|
|
82
|
+
for (const sub of subdirs.slice().sort()) {
|
|
83
|
+
const subBase = joinPath(base, sub);
|
|
84
|
+
for (const name of await host.listDir(subBase)) out.push(joinPath(subBase, name));
|
|
85
|
+
}
|
|
86
|
+
return out.sort();
|
|
87
|
+
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface HostContext {
|
|
|
80
80
|
readTextFile(path: string): Promise<string>;
|
|
81
81
|
/** List file names (not directories) directly under `dir`. Returns [] if missing. */
|
|
82
82
|
listDir(dir: string): Promise<string[]>;
|
|
83
|
+
/**
|
|
84
|
+
* List sub-directory names (not files) directly under `dir`. Returns [] if missing. Optional:
|
|
85
|
+
* the deploy-by-convention walk uses it to descend one level into `resources/<subdir>/` (a host
|
|
86
|
+
* that can't enumerate directories then deploys only the files directly under `resources/`).
|
|
87
|
+
*/
|
|
88
|
+
listSubdirs?(dir: string): Promise<string[]>;
|
|
83
89
|
/** True if the path exists (file or directory). */
|
|
84
90
|
exists(path: string): Promise<boolean>;
|
|
85
91
|
/** Open (creating if needed) a SQLite database at a filesystem path. */
|
|
@@ -99,8 +105,16 @@ export interface HostContext {
|
|
|
99
105
|
* connector mount then reports the workers as unsupported rather than crashing.
|
|
100
106
|
*/
|
|
101
107
|
importConnectorModule?(entry: string): Promise<void>;
|
|
102
|
-
/**
|
|
103
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Start an HTTP server. Routing is done by the caller inside `handler`. `bindHost` is the
|
|
110
|
+
* network interface to bind (e.g. `"127.0.0.1"` for loopback, `"0.0.0.0"` for all interfaces);
|
|
111
|
+
* the runtime always resolves it from the manifest `network.bind` setting (issue #235,
|
|
112
|
+
* loopback by default). It stays optional so hosts that can't bind-control still satisfy the
|
|
113
|
+
* contract, but the built-in node/deno adapters fail *closed* to loopback when it is omitted —
|
|
114
|
+
* they never inherit a bind-all default — so the secure-by-default guarantee can't silently
|
|
115
|
+
* regress.
|
|
116
|
+
*/
|
|
117
|
+
serveHttp(port: number, handler: HttpHandler, bindHost?: string): Promise<HttpServer>;
|
|
104
118
|
/**
|
|
105
119
|
* Recursively watch the app root for file changes, invoking `onChange` with the
|
|
106
120
|
* changed path (app-root-relative when the runtime reports it that way). Optional:
|
|
@@ -174,6 +188,20 @@ export function isBpmnError(err: unknown): err is { errorCode: string; message?:
|
|
|
174
188
|
);
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The canonical presence rule for a form identifier ({@link EngineClient.getForm}): an
|
|
193
|
+
* empty or whitespace-only value is treated as *absent*. Returns the *trimmed* value when
|
|
194
|
+
* present (so a padded `" form-123 "` resolves against the space-free deployed key),
|
|
195
|
+
* otherwise `undefined`. This is the single source of truth shared by `getForm`'s
|
|
196
|
+
* resolution gate ({@link EngineClient.getForm} adapters) and the `taskInbox`
|
|
197
|
+
* `/api/form` route's presence check, so the two cannot drift on what counts as "an
|
|
198
|
+
* identifier was provided" (e.g. a whitespace-only `?formKey= ` is absent in both).
|
|
199
|
+
*/
|
|
200
|
+
export function presentFormIdentifier(value: string | undefined): string | undefined {
|
|
201
|
+
const trimmed = value?.trim();
|
|
202
|
+
return trimmed ? trimmed : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
/** A registered worker subscription. */
|
|
178
206
|
export interface WorkerSubscription {
|
|
179
207
|
readonly jobType: string;
|
|
@@ -195,6 +223,37 @@ export interface ProcessInstanceSnapshot {
|
|
|
195
223
|
readonly state: ProcessInstanceState;
|
|
196
224
|
}
|
|
197
225
|
|
|
226
|
+
/**
|
|
227
|
+
* A single open user task as {@link EngineClient.searchUserTasks} reports it. Carries the
|
|
228
|
+
* task's identity plus its resolved form linkage so the `taskInbox` surface (ADR 0026) can
|
|
229
|
+
* fetch and render the linked `.form`. `formKey` is the engine's resolution of a
|
|
230
|
+
* `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form at task-creation
|
|
231
|
+
* time; `externalFormReference` is a form linked outside the deployment. Both are absent for a
|
|
232
|
+
* task with no linked form (the surface then falls back to the raw key-list + complete path).
|
|
233
|
+
*/
|
|
234
|
+
export interface UserTaskSummary {
|
|
235
|
+
readonly userTaskKey: string;
|
|
236
|
+
readonly elementId?: string;
|
|
237
|
+
readonly variables?: Record<string, unknown>;
|
|
238
|
+
/** The resolved key of the linked deployed form, if the task has one. */
|
|
239
|
+
readonly formKey?: string;
|
|
240
|
+
/** An external form reference (a form linked outside the deployment), if any. */
|
|
241
|
+
readonly externalFormReference?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A deployed form's form-js schema plus its identifying metadata, as
|
|
246
|
+
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
247
|
+
* (`{ type: "default", schemaVersion, components: [...] }`) that the surface renders
|
|
248
|
+
* client-side; `formKey`/`formId`/`version` identify the exact deployed form resolved.
|
|
249
|
+
*/
|
|
250
|
+
export interface FormSchema {
|
|
251
|
+
readonly formKey?: string;
|
|
252
|
+
readonly formId?: string;
|
|
253
|
+
readonly version?: number;
|
|
254
|
+
readonly schema: Record<string, unknown>;
|
|
255
|
+
}
|
|
256
|
+
|
|
198
257
|
/**
|
|
199
258
|
* EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
|
|
200
259
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
@@ -218,12 +277,23 @@ export interface EngineClient {
|
|
|
218
277
|
correlationKey?: string;
|
|
219
278
|
variables?: Record<string, unknown>;
|
|
220
279
|
}): Promise<void>;
|
|
221
|
-
/** Search open user tasks (optionally by process instance).
|
|
280
|
+
/** Search open user tasks (optionally by process instance). Each result carries the
|
|
281
|
+
* task's resolved form linkage (`formKey`/`externalFormReference`) when present. */
|
|
222
282
|
searchUserTasks(filter?: {
|
|
223
283
|
processInstanceKey?: string;
|
|
224
284
|
assignee?: string;
|
|
225
285
|
candidateGroup?: string;
|
|
226
|
-
}): Promise<
|
|
286
|
+
}): Promise<UserTaskSummary[]>;
|
|
287
|
+
/**
|
|
288
|
+
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
289
|
+
* `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
|
|
290
|
+
* fallback, by `formId`. An empty or whitespace-only identifier is treated as absent,
|
|
291
|
+
* so a blank `formKey` falls through to `formId`. How `formId` is addressed is
|
|
292
|
+
* adapter-specific: an adapter may pass it straight through as the lookup key, or
|
|
293
|
+
* resolve it to the latest deployed form's key. Returns `null` when no matching form
|
|
294
|
+
* exists — the caller then falls back to the no-form path.
|
|
295
|
+
*/
|
|
296
|
+
getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null>;
|
|
227
297
|
/** Complete a user task. */
|
|
228
298
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
229
299
|
/**
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
expandEnvString,
|
|
5
|
+
expandEnv,
|
|
6
|
+
parseManifest,
|
|
7
|
+
workerJobType,
|
|
8
|
+
resolveBindMode,
|
|
9
|
+
resolveBindHost,
|
|
10
|
+
isBindMode,
|
|
11
|
+
bindModeToHost,
|
|
12
|
+
LOOPBACK_HOST,
|
|
13
|
+
ALL_INTERFACES_HOST,
|
|
14
|
+
NETWORK_KEYS,
|
|
15
|
+
type NetworkConfig,
|
|
16
|
+
} from "./manifest.ts";
|
|
4
17
|
|
|
5
18
|
test("expandEnvString: var, default, and empty fallback", () => {
|
|
6
19
|
const env: Record<string, string> = { FOO: "bar" };
|
|
@@ -28,3 +41,50 @@ test("workerJobType returns the taskType", () => {
|
|
|
28
41
|
assert.equal(workerJobType({ taskType: "a", handler: "h" }), "a");
|
|
29
42
|
assert.equal(workerJobType({ taskType: "llm-job", llm: "gpt" }), "llm-job");
|
|
30
43
|
});
|
|
44
|
+
|
|
45
|
+
test("bind mode defaults to loopback when the manifest omits network (issue #235)", () => {
|
|
46
|
+
assert.equal(resolveBindMode({}), "loopback");
|
|
47
|
+
assert.equal(resolveBindMode({ network: {} }), "loopback");
|
|
48
|
+
assert.equal(resolveBindHost({}), LOOPBACK_HOST);
|
|
49
|
+
assert.equal(resolveBindHost({}), "127.0.0.1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("manifest network.bind selects the interface", () => {
|
|
53
|
+
assert.equal(resolveBindMode({ network: { bind: "all" } }), "all");
|
|
54
|
+
assert.equal(resolveBindHost({ network: { bind: "all" } }), ALL_INTERFACES_HOST);
|
|
55
|
+
assert.equal(resolveBindHost({ network: { bind: "all" } }), "0.0.0.0");
|
|
56
|
+
assert.equal(resolveBindHost({ network: { bind: "loopback" } }), "127.0.0.1");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Drift guard: NETWORK_KEYS is the validator's single source of truth for `network`'s allowed
|
|
60
|
+
// keys. Keep it complete as NetworkConfig grows — every declared key must be listed, or the
|
|
61
|
+
// validator would reject a legitimate field. The `satisfies` pins each entry to a real key.
|
|
62
|
+
test("NETWORK_KEYS lists every NetworkConfig key (issue #235)", () => {
|
|
63
|
+
const sample = { bind: "loopback" } satisfies Required<NetworkConfig>;
|
|
64
|
+
for (const key of Object.keys(sample)) {
|
|
65
|
+
assert.ok(
|
|
66
|
+
NETWORK_KEYS.some((known) => known === key),
|
|
67
|
+
`NETWORK_KEYS is missing the '${key}' NetworkConfig key`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("URBAN_BIND env overrides the manifest, invalid values are ignored", () => {
|
|
73
|
+
const env = (v: string | undefined) => (n: string) => (n === "URBAN_BIND" ? v : undefined);
|
|
74
|
+
// env override wins over the manifest in both directions
|
|
75
|
+
assert.equal(resolveBindMode({ network: { bind: "loopback" } }, env("all")), "all");
|
|
76
|
+
assert.equal(resolveBindMode({ network: { bind: "all" } }, env("loopback")), "loopback");
|
|
77
|
+
assert.equal(resolveBindHost({ network: { bind: "loopback" } }, env("all")), "0.0.0.0");
|
|
78
|
+
// an invalid or empty env value falls through to the manifest/default
|
|
79
|
+
assert.equal(resolveBindMode({ network: { bind: "all" } }, env("bogus")), "all");
|
|
80
|
+
assert.equal(resolveBindMode({}, env("")), "loopback");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("isBindMode / bindModeToHost", () => {
|
|
84
|
+
assert.equal(isBindMode("loopback"), true);
|
|
85
|
+
assert.equal(isBindMode("all"), true);
|
|
86
|
+
assert.equal(isBindMode("nope"), false);
|
|
87
|
+
assert.equal(isBindMode(undefined), false);
|
|
88
|
+
assert.equal(bindModeToHost("loopback"), "127.0.0.1");
|
|
89
|
+
assert.equal(bindModeToHost("all"), "0.0.0.0");
|
|
90
|
+
});
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
// used to cause.
|
|
10
10
|
|
|
11
11
|
import type { HostContext } from "./host.ts";
|
|
12
|
-
import
|
|
12
|
+
import { isRecord } from "./guards.ts";
|
|
13
|
+
import type {
|
|
14
|
+
AppManifest,
|
|
15
|
+
InstanceTracking as SchemaInstanceTracking,
|
|
16
|
+
Worker,
|
|
17
|
+
} from "@nanobpm/nano-app-schema";
|
|
13
18
|
|
|
14
19
|
export type {
|
|
15
20
|
ActionDecl,
|
|
@@ -21,7 +26,6 @@ export type {
|
|
|
21
26
|
DataSource,
|
|
22
27
|
DomainField,
|
|
23
28
|
DomainType,
|
|
24
|
-
InstanceTracking,
|
|
25
29
|
LlmBinding,
|
|
26
30
|
Models,
|
|
27
31
|
PagesSurface,
|
|
@@ -34,11 +38,141 @@ export type {
|
|
|
34
38
|
Worker,
|
|
35
39
|
} from "@nanobpm/nano-app-schema";
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector.
|
|
43
|
+
*
|
|
44
|
+
* `terminalStatuses` is landing in the canonical schema (`@nanobpm/nano-app-schema`,
|
|
45
|
+
* Magikcraft/nano-bpm#769): an exclusion list — the runtime polls every row whose
|
|
46
|
+
* `statusField` is NOT in it — so a newly-added non-terminal status is reconciled by
|
|
47
|
+
* default instead of silently dropping out of an allow-list. Until that publishes and this
|
|
48
|
+
* package's dep is bumped, extend the schema type here so the reconciler can consume the
|
|
49
|
+
* field. Drop this augmentation and re-export `InstanceTracking` from the schema once the
|
|
50
|
+
* dep bump lands (No Drift Surfaces).
|
|
51
|
+
*/
|
|
52
|
+
export type InstanceTracking = SchemaInstanceTracking & {
|
|
53
|
+
/** Values of `statusField` considered finished. When set, every row NOT in one of these
|
|
54
|
+
* is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
|
|
55
|
+
readonly terminalStatuses?: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
|
|
60
|
+
* a non-empty array of non-empty strings. Both the reconciler's row selection and the manifest
|
|
61
|
+
* validator gate on this one predicate, so "is this selector active?" has a single definition and
|
|
62
|
+
* the two can't drift (No Drift Surfaces). Anything else — `undefined`, an empty array, a bare
|
|
63
|
+
* string, or an array holding a non-string/empty entry — is treated as *not configured*: the
|
|
64
|
+
* reconciler falls through to the fail-open poll-all path and the validator flags the malformed
|
|
65
|
+
* shape at author time (rather than letting e.g. `new Set("abandoned")` silently become a set of
|
|
66
|
+
* characters, or `activeStatuses.map(...)` crash on a non-array).
|
|
67
|
+
*/
|
|
68
|
+
export function isConfiguredStatusSelector(v: unknown): v is readonly string[] {
|
|
69
|
+
return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === "string" && s.length > 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
/** The job type a worker subscribes to (schema key: `taskType`). */
|
|
38
73
|
export function workerJobType(w: Worker): string | undefined {
|
|
39
74
|
return w.taskType;
|
|
40
75
|
}
|
|
41
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The valid HTTP bind modes, in declaration order. This is the single runtime source of
|
|
79
|
+
* truth for the set: {@link BindMode}, {@link isBindMode}, and the manifest validator
|
|
80
|
+
* (`collectManifestIssues`) all derive from it, so the allowed values live in one place.
|
|
81
|
+
*/
|
|
82
|
+
export const BIND_MODES = ["loopback", "all"] as const;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which interface the app's embedded HTTP server binds to (issue #235):
|
|
86
|
+
* - `"loopback"` (default): `127.0.0.1` — secure-by-default; refuses off-box connections.
|
|
87
|
+
* - `"all"`: `0.0.0.0` — reachable from other hosts on the LAN (distributed worker fleet).
|
|
88
|
+
*/
|
|
89
|
+
export type BindMode = (typeof BIND_MODES)[number];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* App-level network settings (issue #235). Absent ⇒ loopback.
|
|
93
|
+
*
|
|
94
|
+
* Mirrored locally — like the `api` binding (ADR 0058) — until `@nanobpm/nano-app-schema`
|
|
95
|
+
* folds `network` into `AppManifest` (the schema PR lands first). The manifest shape is
|
|
96
|
+
* owned by that package (ADR 0027), so we deliberately do NOT augment `AppManifest` via
|
|
97
|
+
* `declare module` (CI bans it): the field is threaded through the runtime-side helpers
|
|
98
|
+
* that take a `{ network?: NetworkConfig }` view and is read off the raw manifest object.
|
|
99
|
+
* Once the schema ships `network`, this local type (and the allow-list entry in
|
|
100
|
+
* validate.ts) can be deleted and the field flows from the schema like every other block.
|
|
101
|
+
*/
|
|
102
|
+
export interface NetworkConfig {
|
|
103
|
+
/** HTTP bind interface. Default `"loopback"`. */
|
|
104
|
+
bind?: BindMode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The keys the runtime-side `network` block understands, as the single source of truth for
|
|
109
|
+
* the validator's `additionalProperties: false` check under `network`. Typed as
|
|
110
|
+
* `(keyof NetworkConfig)[]` so it can only ever list real {@link NetworkConfig} keys — a
|
|
111
|
+
* drift test (`manifest.test.ts`) asserts it stays complete as the block grows.
|
|
112
|
+
*/
|
|
113
|
+
export const NETWORK_KEYS: readonly (keyof NetworkConfig)[] = ["bind"];
|
|
114
|
+
|
|
115
|
+
/** A runtime-side view of a manifest carrying the pending-schema `network` block. */
|
|
116
|
+
export interface WithNetwork {
|
|
117
|
+
network?: NetworkConfig;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Loopback bind address — secure/local default. */
|
|
121
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
122
|
+
/** All-interfaces IPv4 bind address — LAN / distributed fleet. */
|
|
123
|
+
export const ALL_INTERFACES_HOST = "0.0.0.0";
|
|
124
|
+
|
|
125
|
+
/** Environment variable that overrides the manifest bind mode for ops. */
|
|
126
|
+
export const BIND_ENV_VAR = "URBAN_BIND";
|
|
127
|
+
|
|
128
|
+
/** True when `v` is a valid {@link BindMode}. */
|
|
129
|
+
export function isBindMode(v: unknown): v is BindMode {
|
|
130
|
+
return BIND_MODES.some((mode) => mode === v);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the pending-schema `network.bind` off a raw manifest object. Because `network` is
|
|
135
|
+
* mirrored locally (not yet in `AppManifest`'s static shape), it is read reflectively —
|
|
136
|
+
* the same way the `api` binding is threaded (ADR 0058) — and validated with
|
|
137
|
+
* {@link isBindMode} so this always returns a real {@link BindMode} or `undefined`.
|
|
138
|
+
*/
|
|
139
|
+
function manifestBind(manifest: AppManifest | WithNetwork): BindMode | undefined {
|
|
140
|
+
const network = Reflect.get(manifest, "network");
|
|
141
|
+
if (!isRecord(network)) return undefined;
|
|
142
|
+
return isBindMode(network.bind) ? network.bind : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the effective HTTP bind mode: the `URBAN_BIND` env override wins when set to a
|
|
147
|
+
* valid value, else the manifest's `network.bind`, else `"loopback"` (secure by default).
|
|
148
|
+
* An env value that is present but invalid is ignored (falls through to the manifest/default);
|
|
149
|
+
* callers that want to surface that can check {@link isBindMode} on the raw env value.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveBindMode(
|
|
152
|
+
manifest: AppManifest | WithNetwork,
|
|
153
|
+
lookup: (name: string) => string | undefined = () => undefined,
|
|
154
|
+
): BindMode {
|
|
155
|
+
const envRaw = lookup(BIND_ENV_VAR);
|
|
156
|
+
if (isBindMode(envRaw)) return envRaw;
|
|
157
|
+
return manifestBind(manifest) ?? "loopback";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Map a {@link BindMode} to the concrete host address passed to the HTTP adapter. */
|
|
161
|
+
export function bindModeToHost(mode: BindMode): string {
|
|
162
|
+
return mode === "all" ? ALL_INTERFACES_HOST : LOOPBACK_HOST;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the concrete bind host address from the manifest + environment.
|
|
167
|
+
* `"loopback"` ⇒ `127.0.0.1` (default), `"all"` ⇒ `0.0.0.0`.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveBindHost(
|
|
170
|
+
manifest: AppManifest | WithNetwork,
|
|
171
|
+
lookup: (name: string) => string | undefined = () => undefined,
|
|
172
|
+
): string {
|
|
173
|
+
return bindModeToHost(resolveBindMode(manifest, lookup));
|
|
174
|
+
}
|
|
175
|
+
|
|
42
176
|
const ENV_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
43
177
|
|
|
44
178
|
/**
|
|
@@ -29,6 +29,7 @@ const engine: EngineClient = {
|
|
|
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 () => {} }),
|
|
@@ -28,6 +28,7 @@ const engine: EngineClient = {
|
|
|
28
28
|
cancelInstance: async () => {},
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
|
+
getForm: async () => null,
|
|
31
32
|
searchProcessInstances: async () => [],
|
|
32
33
|
completeUserTask: async () => {},
|
|
33
34
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -73,6 +73,7 @@ function cancelEngine(opts: {
|
|
|
73
73
|
createInstance: notUsed("createInstance"),
|
|
74
74
|
publishMessage: notUsed("publishMessage"),
|
|
75
75
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
76
|
+
getForm: notUsed("getForm"),
|
|
76
77
|
completeUserTask: notUsed("completeUserTask"),
|
|
77
78
|
registerWorker: notUsed("registerWorker"),
|
|
78
79
|
close: notUsed("close"),
|
|
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
|
|
|
17
17
|
async cancelInstance(): Promise<void> {}
|
|
18
18
|
async publishMessage(): Promise<void> {}
|
|
19
19
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
20
|
+
async getForm(): Promise<null> { return null; }
|
|
20
21
|
async completeUserTask(): Promise<void> {}
|
|
21
22
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
22
23
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
@@ -16,6 +16,7 @@ const noEngine: EngineClient = {
|
|
|
16
16
|
cancelInstance: async () => {},
|
|
17
17
|
publishMessage: async () => {},
|
|
18
18
|
searchUserTasks: async () => [],
|
|
19
|
+
getForm: async () => null,
|
|
19
20
|
completeUserTask: async () => {},
|
|
20
21
|
searchProcessInstances: async () => [],
|
|
21
22
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|