@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.
Files changed (81) 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 +71 -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 +77 -5
  16. package/dist/runtime/core/modules/surfaces.js +192 -18
  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 +9 -6
  23. package/dist/runtime/engine/nanosdk.js +65 -1
  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/derivers/shapes.d.ts +5 -0
  28. package/dist/toolkit/derivers/shapes.js +90 -0
  29. package/dist/toolkit/derivers/worker-io.d.ts +25 -0
  30. package/dist/toolkit/derivers/worker-io.js +104 -11
  31. package/dist/toolkit/fsio.js +9 -0
  32. package/dist/toolkit/gen.d.ts +26 -1
  33. package/dist/toolkit/gen.js +103 -22
  34. package/dist/toolkit/models.d.ts +6 -4
  35. package/dist/toolkit/models.js +7 -4
  36. package/package.json +1 -1
  37. package/src/runtime/adapters/deno.ts +26 -6
  38. package/src/runtime/adapters/node.test.ts +67 -0
  39. package/src/runtime/adapters/node.ts +37 -4
  40. package/src/runtime/core/context.ts +0 -4
  41. package/src/runtime/core/glob.ts +24 -0
  42. package/src/runtime/core/host.ts +74 -4
  43. package/src/runtime/core/manifest.test.ts +61 -1
  44. package/src/runtime/core/manifest.ts +136 -2
  45. package/src/runtime/core/modules/actions.test.ts +1 -0
  46. package/src/runtime/core/modules/api.test.ts +1 -0
  47. package/src/runtime/core/modules/cancel.test.ts +1 -0
  48. package/src/runtime/core/modules/connectors.test.ts +1 -0
  49. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  50. package/src/runtime/core/modules/deploy.test.ts +97 -161
  51. package/src/runtime/core/modules/deploy.ts +69 -37
  52. package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
  53. package/src/runtime/core/modules/instance-tracking.ts +19 -6
  54. package/src/runtime/core/modules/pages.test.ts +71 -3
  55. package/src/runtime/core/modules/pages.ts +77 -5
  56. package/src/runtime/core/modules/surfaces.test.ts +198 -5
  57. package/src/runtime/core/modules/surfaces.ts +191 -18
  58. package/src/runtime/core/modules/triggers.test.ts +1 -0
  59. package/src/runtime/core/modules/workers.test.ts +1 -0
  60. package/src/runtime/core/router.test.ts +10 -1
  61. package/src/runtime/core/router.ts +10 -0
  62. package/src/runtime/core/runtime.test.ts +67 -1
  63. package/src/runtime/core/runtime.ts +28 -9
  64. package/src/runtime/core/validate.test.ts +145 -0
  65. package/src/runtime/core/validate.ts +64 -1
  66. package/src/runtime/engine/nanosdk.test.ts +122 -0
  67. package/src/runtime/engine/nanosdk.ts +72 -2
  68. package/src/runtime/index.ts +11 -4
  69. package/src/runtime/run.ts +0 -1
  70. package/src/toolkit/derivers/shapes.test.ts +66 -0
  71. package/src/toolkit/derivers/shapes.ts +93 -0
  72. package/src/toolkit/derivers/worker-io.test.ts +131 -0
  73. package/src/toolkit/derivers/worker-io.ts +116 -9
  74. package/src/toolkit/fsio.ts +8 -0
  75. package/src/toolkit/gen.test.ts +81 -10
  76. package/src/toolkit/gen.ts +109 -22
  77. package/src/toolkit/models.test.ts +5 -5
  78. package/src/toolkit/models.ts +8 -5
  79. package/dist/runtime/core/modules/templates.d.ts +0 -18
  80. package/dist/runtime/core/modules/templates.js +0 -133
  81. package/src/runtime/core/modules/templates.ts +0 -153
@@ -7,6 +7,12 @@ export interface GenIO {
7
7
  writeText(path: string, content: string): Promise<void>;
8
8
  /** File names (not paths) in a directory; empty if it does not exist. */
9
9
  listDir(path: string): Promise<string[]>;
10
+ /**
11
+ * Sub-directory names (not files) in a directory; empty if it does not exist. Optional: the
12
+ * deploy-by-convention model scan uses it to descend one level into `resources/<subdir>/`. When
13
+ * absent, only files directly under `resources/` are scanned.
14
+ */
15
+ listSubdirs?(path: string): Promise<string[]>;
10
16
  exists(path: string): Promise<boolean>;
11
17
  /**
12
18
  * Import an app module and return its exports. Optional: only present when the caller runs on
@@ -50,7 +56,26 @@ declare function join(root: string, rel: string): string;
50
56
  declare function dirOf(p: string): string;
51
57
  /** Resolve a `dir/*.ext` (or literal) manifest pattern to file paths relative to root. */
52
58
  export declare function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]>;
53
- /** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
59
+ /** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
60
+ * runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
61
+ * deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
62
+ export declare const RESOURCES_DIR = "resources";
63
+ /**
64
+ * Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
65
+ * and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
66
+ * and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
67
+ * `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
68
+ */
69
+ export declare function discoverResourceModels(root: string, io: GenIO): Promise<string[]>;
70
+ /**
71
+ * Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
72
+ * `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
73
+ * discovered by convention under `resources/`; with a `models` block declared, `models.processes`
74
+ * is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
75
+ * alone would fall back to convention even when a `models` block is present for other overrides
76
+ * (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
77
+ * gen/runtime drift.
78
+ */
54
79
  export declare function readModels(root: string, io: GenIO, manifest: {
55
80
  models?: {
56
81
  processes?: string[];
@@ -4,10 +4,11 @@
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
5
  import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.js";
6
6
  import { deriveMigrations } from "./derivers/migrations.js";
7
- import { deriveDomain } from "./derivers/domain.js";
8
- import { deriveWorkerBindings } from "./derivers/worker-io.js";
7
+ import { emitDomainModel, registryFromManifest, sourcesFromManifest } from "./derivers/domain.js";
8
+ import { byModelPath, deriveWorkerBindings, detectEnvelopeConflicts, DOMAIN_DTS, formatEnvelopeConflict, scanModelWorkers } from "./derivers/worker-io.js";
9
9
  import { deriveMeta } from "./derivers/meta.js";
10
10
  import { deriveMessageBindings } from "./derivers/messages.js";
11
+ import { resolveShapes, scanModelShapes } from "./derivers/shapes.js";
11
12
  import { deriveApi } from "./derivers/api.js";
12
13
  import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.js";
13
14
  import { deriveModels, MODEL_PROVENANCE } from "./models.js";
@@ -45,15 +46,63 @@ export async function expandPattern(root, io, pattern) {
45
46
  .map((n) => (dir === "." ? n : `${dir}/${n}`))
46
47
  .sort();
47
48
  }
48
- /** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
49
+ /** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
50
+ * runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
51
+ * deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
52
+ export const RESOURCES_DIR = "resources";
53
+ /** Model file extensions the `nano:shape`/code-first scan reads by convention (BPMN + DMN). */
54
+ const MODEL_EXTS = [".bpmn", ".dmn"];
55
+ /**
56
+ * Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
57
+ * and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
58
+ * and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
59
+ * `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
60
+ */
61
+ export async function discoverResourceModels(root, io) {
62
+ const isModel = (n) => MODEL_EXTS.some((e) => n.endsWith(e));
63
+ const out = [];
64
+ for (const n of await io.listDir(join(root, RESOURCES_DIR))) {
65
+ if (isModel(n))
66
+ out.push(`${RESOURCES_DIR}/${n}`);
67
+ }
68
+ const subdirs = io.listSubdirs ? await io.listSubdirs(join(root, RESOURCES_DIR)) : [];
69
+ for (const sub of subdirs.slice().sort()) {
70
+ for (const n of await io.listDir(join(root, `${RESOURCES_DIR}/${sub}`))) {
71
+ if (isModel(n))
72
+ out.push(`${RESOURCES_DIR}/${sub}/${n}`);
73
+ }
74
+ }
75
+ return out.sort();
76
+ }
77
+ /**
78
+ * Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
79
+ * `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
80
+ * discovered by convention under `resources/`; with a `models` block declared, `models.processes`
81
+ * is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
82
+ * alone would fall back to convention even when a `models` block is present for other overrides
83
+ * (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
84
+ * gen/runtime drift.
85
+ */
49
86
  export async function readModels(root, io, manifest) {
50
- const procPatterns = manifest.models?.processes ?? [];
51
- const models = [];
52
- for (const pat of procPatterns) {
53
- for (const rel of await expandPattern(root, io, pat)) {
54
- models.push({ path: rel, xml: await io.readText(join(root, rel)) });
87
+ const byConvention = manifest.models === undefined;
88
+ let rels;
89
+ if (byConvention) {
90
+ rels = await discoverResourceModels(root, io);
91
+ }
92
+ else {
93
+ rels = [];
94
+ for (const pat of manifest.models?.processes ?? []) {
95
+ rels.push(...(await expandPattern(root, io, pat)));
55
96
  }
56
97
  }
98
+ const models = [];
99
+ const seen = new Set();
100
+ for (const rel of rels) {
101
+ if (seen.has(rel))
102
+ continue;
103
+ seen.add(rel);
104
+ models.push({ path: rel, xml: await io.readText(join(root, rel)) });
105
+ }
57
106
  return models;
58
107
  }
59
108
  /** Collect all artifacts a run would produce, without touching disk beyond reads. */
@@ -69,28 +118,60 @@ async function collectAll(opts) {
69
118
  const manifest = JSON.parse(await io.readText(manifestPath));
70
119
  const artifacts = [];
71
120
  const warnings = [];
72
- // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
73
- if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
74
- artifacts.push(...deriveMigrations(manifest));
75
- artifacts.push(...deriveDomain(manifest));
76
- }
77
- // 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
78
- // `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
79
- // derivers below so worker I/O works for a code-first app even on a `--no-models` run.
121
+ // 1. Resolve the app's models first (code-first `workflows/*.ts` → BPMN, plus authored on-disk
122
+ // `.bpmn`): the fused domain model (below) lifts `nano:shape` envelopes *from* the models, so
123
+ // they must be in hand before the domain + worker/message type-contracts are derived.
80
124
  const derived = await deriveModels(root, io, manifest);
125
+ // The derived `.bpmn` are emitted only when `emitModels` (default), but always fed to the
126
+ // type-contract derivers so worker I/O works for a code-first app even on a `--no-models` run.
81
127
  if (emitModels)
82
128
  artifacts.push(...derived.artifacts);
83
- // 3. models (authored on-disk + derived) → worker I/O index + meta accessor + message map.
84
- // Drop any on-disk model whose path a derived model already covers, to avoid double-counting.
129
+ const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
130
+ const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
131
+ const models = [...diskModels, ...derived.models];
132
+ // 2. Fused domain model (ADR 0040): the `DomainTypes` registry is the union of the manifest
133
+ // `types` registry and the model-authored `nano:shape` envelopes, resolved through the same
134
+ // fuse the console reifier (`envelope_scan.rs`) uses. Without this the standalone `urban gen`
135
+ // would honour only `manifest.types` and silently ignore every model-authored data envelope,
136
+ // so worker/message I/O against those envelopes would fall back to the untyped `WorkerVars`.
137
+ const manifestRegistry = registryFromManifest(manifest);
138
+ const sources = sourcesFromManifest(manifest);
139
+ const shapeDecls = models.flatMap((m) => scanModelShapes(m.xml));
140
+ const shapeResolution = resolveShapes(shapeDecls, manifestRegistry, sources);
141
+ const fusedTypes = { ...manifestRegistry, ...shapeResolution.types };
142
+ for (const d of shapeResolution.diagnostics) {
143
+ warnings.push(`data-envelope shape "${d.shape}" (${d.kind}): ${d.message}`);
144
+ }
145
+ // 3. types → migrations (persisted `manifest.types` only — shapes are transient wire envelopes,
146
+ // never tables) + the fused domain row types (`DomainTables` spine + `DomainTypes` registry).
147
+ if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
148
+ artifacts.push(...deriveMigrations(manifest));
149
+ }
150
+ if (!opts.modelsOnly && Object.keys(fusedTypes).length > 0) {
151
+ artifacts.push({
152
+ path: `${GENERATED_DIR}/${DOMAIN_DTS}`,
153
+ content: emitDomainModel(sources, manifest.data?.default ?? "app", fusedTypes),
154
+ });
155
+ }
156
+ // 4. models (authored on-disk + derived) → worker I/O index + meta accessor + message map. The
157
+ // declared type ids include the fused shapes, so an envelope ref to a model-authored shape
158
+ // resolves to its `DomainTypes[...]` entry instead of degrading to the untyped fallback.
85
159
  if (!opts.modelsOnly) {
86
- const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
87
- const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
88
- const models = [...diskModels, ...derived.models];
89
160
  if (models.length > 0) {
90
- const declaredTypeIds = Object.keys(manifest.types ?? {});
161
+ const declaredTypeIds = Object.keys(fusedTypes);
91
162
  artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
92
163
  artifacts.push(...deriveMeta(models));
93
164
  artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
165
+ // Surface genuine data-envelope conflicts: a taskType whose variants disagree on a field's
166
+ // TYPE. The emitted contract is the union of the variants (sound for a shared handler), so a
167
+ // type clash would silently widen to `A | B` — warn instead so the author unifies the envelope.
168
+ const scannedWorkers = [...models]
169
+ .sort(byModelPath)
170
+ .flatMap((m) => scanModelWorkers(m.xml))
171
+ .map((w) => ({ taskType: w.taskType, inputType: w.in, outputType: w.out }));
172
+ for (const c of detectEnvelopeConflicts(scannedWorkers, fusedTypes)) {
173
+ warnings.push(formatEnvelopeConflict(c));
174
+ }
94
175
  }
95
176
  }
96
177
  // 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
@@ -4,9 +4,11 @@ import type { GenIO } from "./gen.ts";
4
4
  import type { ModelSource } from "./derivers/worker-io.ts";
5
5
  /** Default source glob for code-first flows (matches the code-first scaffold layout). */
6
6
  export declare const DEFAULT_WORKFLOW_PATTERNS: string[];
7
- /** Default output dir for derived models (matches the model-first scaffold, so a derived
8
- * model is ejectable to model-first and picked up by `models.processes: ["processes/*.bpmn"]`). */
9
- export declare const PROCESSES_DIR = "processes";
7
+ /** Default output dir for derived models when the manifest declares no `models.processes`: under
8
+ * the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
9
+ * exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
10
+ * `processesOutDir` derives the dir from that glob instead (the model-first override). */
11
+ export declare const PROCESSES_DIR = "resources/processes";
10
12
  /** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
11
13
  * stale sweep deletes ONLY derived output — never an authored, human-owned model. */
12
14
  export declare const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts \u2014 do not edit; edit the flow and re-run `urban gen`.";
@@ -29,7 +31,7 @@ export interface ModelError {
29
31
  message: string;
30
32
  }
31
33
  export interface DerivedModels {
32
- /** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
34
+ /** `resources/processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
33
35
  artifacts: DerivedArtifact[];
34
36
  /** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
35
37
  models: ModelSource[];
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
4
4
  // imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
5
- // `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn`.
5
+ // `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `resources/processes/<id>.bpmn`
6
+ // (the deploy-by-convention `resources/` tree, ADR 0062 — see `PROCESSES_DIR`).
6
7
  // Executing the app's TypeScript needs a runtime module loader, so this is gated on the
7
8
  // optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
8
9
  // loader is absent — or the app authors its models directly (model-first) — derivation is a
@@ -12,9 +13,11 @@ import { expandPattern, joinPath } from "./gen.js";
12
13
  import { isRecord } from "../runtime/core/guards.js";
13
14
  /** Default source glob for code-first flows (matches the code-first scaffold layout). */
14
15
  export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
15
- /** Default output dir for derived models (matches the model-first scaffold, so a derived
16
- * model is ejectable to model-first and picked up by `models.processes: ["processes/*.bpmn"]`). */
17
- export const PROCESSES_DIR = "processes";
16
+ /** Default output dir for derived models when the manifest declares no `models.processes`: under
17
+ * the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
18
+ * exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
19
+ * `processesOutDir` derives the dir from that glob instead (the model-first override). */
20
+ export const PROCESSES_DIR = "resources/processes";
18
21
  /** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
19
22
  * stale sweep deletes ONLY derived output — never an authored, human-owned model. */
20
23
  export const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts — do not edit; edit the flow and re-run `urban gen`.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -17,6 +17,7 @@ import type {
17
17
  SqliteDb,
18
18
  } from "../core/host.ts";
19
19
  import { resolveModulePath } from "../core/module-path.ts";
20
+ import { LOOPBACK_HOST } from "../core/manifest.ts";
20
21
  import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.ts";
21
22
 
22
23
  type SqliteParam = string | number | bigint | Uint8Array | null;
@@ -48,11 +49,11 @@ interface DenoGlobal {
48
49
  env: { get(name: string): string | undefined };
49
50
  cwd(): string;
50
51
  readTextFile(path: string): Promise<string>;
51
- readDir(path: string): AsyncIterable<{ name: string; isFile: boolean }>;
52
+ readDir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>;
52
53
  stat(path: string): Promise<unknown>;
53
54
  watchFs(paths: string | string[], options?: { recursive?: boolean }): DenoFsWatcher;
54
55
  serve(
55
- opts: { port: number; onListen?: (a: { port: number }) => void },
56
+ opts: { port: number; hostname?: string; onListen?: (a: { port: number }) => void },
56
57
  handler: (req: Request) => Response | Promise<Response>,
57
58
  ): DenoHttpServer;
58
59
  }
@@ -104,6 +105,17 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
104
105
  return [];
105
106
  }
106
107
  },
108
+ async listSubdirs(dir) {
109
+ try {
110
+ const names: string[] = [];
111
+ for await (const e of Deno.readDir(abs(dir))) {
112
+ if (e.isDirectory) names.push(e.name);
113
+ }
114
+ return names;
115
+ } catch {
116
+ return [];
117
+ }
118
+ },
107
119
  async exists(p) {
108
120
  try {
109
121
  await Deno.stat(abs(p));
@@ -123,8 +135,8 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
123
135
  const mod: Promise<Record<string, unknown>> = import(href);
124
136
  return mod;
125
137
  },
126
- async serveHttp(port, handler) {
127
- return startDenoServer(port, handler);
138
+ async serveHttp(port, handler, bindHost) {
139
+ return startDenoServer(port, handler, bindHost);
128
140
  },
129
141
  watch(onChange) {
130
142
  const w = Deno.watchFs(cwd, { recursive: true });
@@ -177,9 +189,17 @@ function wrapSqlite(db: DatabaseSync): SqliteDb {
177
189
  };
178
190
  }
179
191
 
180
- function startDenoServer(port: number, handler: HttpHandler): Promise<HttpServer> {
192
+ function startDenoServer(
193
+ port: number,
194
+ handler: HttpHandler,
195
+ bindHost?: string,
196
+ ): Promise<HttpServer> {
181
197
  return new Promise<HttpServer>((resolveServer) => {
182
- const server = Deno.serve({ port, onListen: ({ port: p }) => {
198
+ // `hostname` binds the interface (issue #235): `"127.0.0.1"` loopback-only, `"0.0.0.0"`
199
+ // all interfaces. When no bind host is resolved we fail *closed* to loopback rather than
200
+ // inheriting Deno's default, so a caller that omits it can never silently expose the server
201
+ // off-box.
202
+ const server = Deno.serve({ port, hostname: bindHost ?? LOOPBACK_HOST, onListen: ({ port: p }) => {
183
203
  resolveServer({
184
204
  port: p,
185
205
  stop: () => server.shutdown(),
@@ -0,0 +1,67 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as http from "node:http";
4
+ import { createNodeHost } from "./node.ts";
5
+ import type { HttpServer } from "../core/host.ts";
6
+
7
+ // Issue #235: the node adapter must bind the interface it is handed so the manifest's
8
+ // `network.bind` setting actually controls off-box reachability. We assert the *listen host*
9
+ // the socket bound to for each setting by reading the native server's address.
10
+ function boundAddress(server: HttpServer): string {
11
+ const native = server.native;
12
+ assert.ok(native instanceof http.Server, "adapter exposes a node http.Server as native");
13
+ const addr = native.address();
14
+ assert.ok(addr && typeof addr === "object", "server has a bound AddressInfo");
15
+ return addr.address;
16
+ }
17
+
18
+ test("serveHttp binds loopback when handed 127.0.0.1 (refuses off-box)", async () => {
19
+ const host = createNodeHost({ log: () => {} });
20
+ const server = await host.serveHttp(0, () => ({ status: 200 }), "127.0.0.1");
21
+ try {
22
+ assert.equal(boundAddress(server), "127.0.0.1");
23
+ } finally {
24
+ await server.stop();
25
+ }
26
+ });
27
+
28
+ test("serveHttp binds all interfaces when handed 0.0.0.0 (reachable on the LAN)", async () => {
29
+ const host = createNodeHost({ log: () => {} });
30
+ const server = await host.serveHttp(0, () => ({ status: 200 }), "0.0.0.0");
31
+ try {
32
+ assert.equal(boundAddress(server), "0.0.0.0");
33
+ } finally {
34
+ await server.stop();
35
+ }
36
+ });
37
+
38
+ // Issue #235 (fail closed): omitting the bind host must NOT inherit Node's bind-all default —
39
+ // a caller that forgets to resolve one should still get loopback, never off-box exposure.
40
+ test("serveHttp fails closed to loopback when no bind host is given", async () => {
41
+ const host = createNodeHost({ log: () => {} });
42
+ const server = await host.serveHttp(0, () => ({ status: 200 }));
43
+ try {
44
+ assert.equal(boundAddress(server), "127.0.0.1");
45
+ } finally {
46
+ await server.stop();
47
+ }
48
+ });
49
+
50
+ // A bind failure (EADDRINUSE) must reject the serveHttp promise instead of hanging forever
51
+ // waiting for a `listening` event that never fires.
52
+ test("serveHttp rejects when the port is already in use (does not hang)", async () => {
53
+ const host = createNodeHost({ log: () => {} });
54
+ const first = await host.serveHttp(0, () => ({ status: 200 }), "127.0.0.1");
55
+ const taken = first.native;
56
+ assert.ok(taken instanceof http.Server);
57
+ const addr = taken.address();
58
+ assert.ok(addr && typeof addr === "object");
59
+ try {
60
+ await assert.rejects(
61
+ () => host.serveHttp(addr.port, () => ({ status: 200 }), "127.0.0.1"),
62
+ (err: NodeJS.ErrnoException) => err.code === "EADDRINUSE",
63
+ );
64
+ } finally {
65
+ await first.stop();
66
+ }
67
+ });
@@ -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(port: number, handler: HttpHandler): Promise<HttpServer> {
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
- await new Promise<void>((res) => server.listen(port, res));
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. */
@@ -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
+ }
@@ -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
- /** Start an HTTP server. Routing is done by the caller inside `handler`. */
103
- serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
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<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]>;
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
  /**