@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.
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 +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 +34 -2
  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/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 +74 -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 +198 -5
  53. package/src/runtime/core/modules/surfaces.ts +191 -18
  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 +122 -0
  63. package/src/runtime/engine/nanosdk.ts +72 -2
  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
@@ -9,6 +9,7 @@
9
9
  // silently — the whole point of consuming the package rather than vendoring a copy.
10
10
  import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
11
11
  import { isRecord } from "./guards.js";
12
+ import { BIND_MODES, NETWORK_KEYS, isBindMode, isConfiguredStatusSelector } from "./manifest.js";
12
13
  export class ManifestValidationError extends Error {
13
14
  issues;
14
15
  constructor(issues) {
@@ -32,6 +33,13 @@ export function collectManifestIssues(m) {
32
33
  issues.push({ path: req, message: "required by nano-app.schema.json" });
33
34
  }
34
35
  const allowed = new Set(Object.keys(S.properties ?? {}));
36
+ // `network` (issue #235) is threaded as a runtime-side setting until
37
+ // @nanobpm/nano-app-schema ships the field in its JSON Schema; allow it here so the
38
+ // envelope check doesn't reject it. Remove this once the schema owns `network` (it
39
+ // then flows from `S.properties` like every other block). The matching local type
40
+ // (NetworkConfig) lives in manifest.ts — mirrored like the `api` binding (ADR 0058),
41
+ // never via a `declare module` augmentation (CI bans augmenting the schema type).
42
+ allowed.add("network");
35
43
  if (S.additionalProperties === false) {
36
44
  for (const k of Object.keys(obj)) {
37
45
  if (!allowed.has(k)) {
@@ -39,6 +47,28 @@ export function collectManifestIssues(m) {
39
47
  }
40
48
  }
41
49
  }
50
+ if ("network" in obj) {
51
+ const network = isRecord(obj.network) ? obj.network : undefined;
52
+ if (!network) {
53
+ issues.push({ path: "network", message: "must be an object" });
54
+ }
55
+ else {
56
+ if ("bind" in network && !isBindMode(network.bind)) {
57
+ issues.push({
58
+ path: "network.bind",
59
+ message: `must be one of: ${BIND_MODES.map((mode) => `"${mode}"`).join(", ")}`,
60
+ });
61
+ }
62
+ // `network` is mirrored runtime-side until the schema owns it, so this is its only
63
+ // validation surface: mirror the schema's `additionalProperties: false` intent and
64
+ // reject unknown keys so typos (e.g. `binn`) fail loudly instead of silently no-op'ing.
65
+ for (const k of Object.keys(network)) {
66
+ if (!NETWORK_KEYS.some((known) => known === k)) {
67
+ issues.push({ path: `network.${k}`, message: "unknown key (network additionalProperties: false)" });
68
+ }
69
+ }
70
+ }
71
+ }
42
72
  if ("schemaVersion" in obj && obj.schemaVersion !== 1) {
43
73
  issues.push({ path: "schemaVersion", message: "must be 1" });
44
74
  }
@@ -153,12 +183,46 @@ export function collectManifestIssues(m) {
153
183
  message: "missing onTerminated.set patch (a non-empty column → value map)",
154
184
  });
155
185
  }
156
- if (Array.isArray(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
186
+ // `activeStatuses` (fail-closed allow-list) and `terminalStatuses` (fail-open exclusion) are
187
+ // both consumed as arrays of status strings. A malformed value — a bare string, or an array
188
+ // holding a non-string/empty entry — would misbehave at runtime (`new Set("abandoned")`
189
+ // becomes a set of characters; `activeStatuses.map(...)` crashes), so reject any non-`unset`
190
+ // shape that isn't a non-empty array of non-empty strings. An empty array is treated as
191
+ // "unset" (the runtime's `isConfiguredStatusSelector` gate does the same), so it is allowed.
192
+ for (const sel of ["activeStatuses", "terminalStatuses"]) {
193
+ const v = b?.[sel];
194
+ const malformed = v !== undefined &&
195
+ (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.length === 0));
196
+ if (malformed) {
197
+ issues.push({
198
+ path: `instanceTracking[${i}].${sel}`,
199
+ message: `${sel} must be an array of non-empty strings`,
200
+ });
201
+ }
202
+ }
203
+ if (isConfiguredStatusSelector(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
157
204
  issues.push({
158
205
  path: `instanceTracking[${i}].activeStatuses`,
159
206
  message: "activeStatuses requires statusField",
160
207
  });
161
208
  }
209
+ // `terminalStatuses` is the fail-open exclusion selector (poll every row NOT in one of
210
+ // these). Like `activeStatuses` it reads `statusField`, so it too requires one; and the two
211
+ // selectors are mutually exclusive — declaring both is ambiguous, so reject it.
212
+ if (isConfiguredStatusSelector(b?.terminalStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
213
+ issues.push({
214
+ path: `instanceTracking[${i}].terminalStatuses`,
215
+ message: "terminalStatuses requires statusField",
216
+ });
217
+ }
218
+ // Mutual exclusion is gated on the same "configured" predicate the runtime uses, so an empty
219
+ // array (which the runtime treats as unset) does not spuriously trip the both-declared error.
220
+ if (isConfiguredStatusSelector(b?.activeStatuses) && isConfiguredStatusSelector(b?.terminalStatuses)) {
221
+ issues.push({
222
+ path: `instanceTracking[${i}].terminalStatuses`,
223
+ message: "activeStatuses and terminalStatuses are mutually exclusive; declare only one",
224
+ });
225
+ }
162
226
  // `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
163
227
  // value would become a 0-delay hot loop at runtime, so reject it at author time.
164
228
  if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
@@ -1,4 +1,4 @@
1
- import type { EngineClient, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
2
2
  import type { EngineSdkClient } from "./sdk.ts";
3
3
  /** Coerce an engine response's process-instance key to a non-empty string, or
4
4
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -81,6 +81,9 @@ export interface NanoSdkClient {
81
81
  userTaskKey: string;
82
82
  variables?: Record<string, unknown>;
83
83
  }, options?: unknown): Promise<unknown>;
84
+ getFormByKey(input: {
85
+ formKey: string;
86
+ }, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
84
87
  searchProcessInstances(input: {
85
88
  filter?: Record<string, unknown>;
86
89
  page?: Record<string, unknown>;
@@ -137,11 +140,11 @@ export declare class SdkEngineClient implements EngineClient {
137
140
  processInstanceKey?: string;
138
141
  assignee?: string;
139
142
  candidateGroup?: string;
140
- }): Promise<{
141
- userTaskKey: string;
142
- elementId?: string;
143
- variables?: Record<string, unknown>;
144
- }[]>;
143
+ }): Promise<UserTaskSummary[]>;
144
+ getForm(input: {
145
+ formKey?: string;
146
+ formId?: string;
147
+ }): Promise<FormSchema | null>;
145
148
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
146
149
  searchProcessInstances(filter?: {
147
150
  processInstanceKeys?: string[];
@@ -18,7 +18,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
18
18
  }
19
19
  return path;
20
20
  };
21
- import { isBpmnError } from "../core/host.js";
21
+ import { isBpmnError, presentFormIdentifier } from "../core/host.js";
22
22
  import { isRecord } from "../core/guards.js";
23
23
  /** Coerce an engine response's process-instance key to a non-empty string, or
24
24
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -109,13 +109,77 @@ export class SdkEngineClient {
109
109
  this.log("warn", "skipping user task with no key in engine response");
110
110
  return [];
111
111
  }
112
+ // The engine resolves a `formId="X"` linkage to the latest deployed form's key at
113
+ // task creation, so `formKey` (not a form id) is the linkage it reports on the task.
114
+ const formKey = it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
115
+ const externalFormReference = typeof it.externalFormReference === "string" && it.externalFormReference !== ""
116
+ ? it.externalFormReference
117
+ : undefined;
112
118
  return [{
113
119
  userTaskKey: String(userTaskKey),
114
120
  elementId: typeof it.elementId === "string" ? it.elementId : undefined,
115
121
  variables: isRecord(it.variables) ? it.variables : undefined,
122
+ ...(formKey ? { formKey } : {}),
123
+ ...(externalFormReference ? { externalFormReference } : {}),
116
124
  }];
117
125
  });
118
126
  }
127
+ async getForm(input) {
128
+ // The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
129
+ // `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
130
+ // form, so callers pass that key. `formId` is accepted as a fallback identifier for
131
+ // engines that address a form by its id; either way the engine returns the current
132
+ // form or 404s (→ null) when there is no such form.
133
+ //
134
+ // This is the single gate that resolves which identifier addresses the form: an
135
+ // empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
136
+ // shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
137
+ // through to a valid `formId` instead of being taken as a present-but-unresolvable key
138
+ // that short-circuits to null.
139
+ const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
140
+ if (key == null)
141
+ return null;
142
+ let body;
143
+ try {
144
+ body = await this.client.getFormByKey({ formKey: key }, { consistency: { waitUpToMs: 0 } });
145
+ }
146
+ catch (err) {
147
+ this.log("warn", "getForm: engine form fetch failed", {
148
+ key,
149
+ formKey: input.formKey,
150
+ formId: input.formId,
151
+ err: err instanceof Error ? err.message : String(err),
152
+ });
153
+ return null;
154
+ }
155
+ if (!isRecord(body))
156
+ return null;
157
+ const rawSchema = body.schema;
158
+ // The engine serializes the form-js schema as a JSON string; parse it to the object
159
+ // the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
160
+ let schema;
161
+ if (typeof rawSchema === "string") {
162
+ try {
163
+ const parsed = JSON.parse(rawSchema);
164
+ if (isRecord(parsed))
165
+ schema = parsed;
166
+ }
167
+ catch {
168
+ this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
169
+ }
170
+ }
171
+ else if (isRecord(rawSchema)) {
172
+ schema = rawSchema;
173
+ }
174
+ if (!schema)
175
+ return null;
176
+ return {
177
+ schema,
178
+ ...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
179
+ ...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
180
+ ...(typeof body.version === "number" ? { version: body.version } : {}),
181
+ };
182
+ }
119
183
  async completeUserTask(userTaskKey, variables) {
120
184
  await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
121
185
  }
@@ -1,7 +1,7 @@
1
1
  export { createUrbanApp } from "./core/runtime.ts";
2
2
  export type { CreateUrbanAppOptions, MountFlags, UrbanApp, } from "./core/runtime.ts";
3
- export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.ts";
4
- export type { ActionDecl, AppManifest, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
3
+ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.ts";
4
+ export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
5
5
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
7
  export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
@@ -30,9 +30,7 @@ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts"
30
30
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
31
31
  export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
32
32
  export { evalCorrelation } from "./core/modules/triggers.ts";
33
- export { deployModels } from "./core/modules/deploy.ts";
34
- export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
35
- export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
33
+ export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
36
34
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
37
35
  export type { SchedulerDeps } from "./core/modules/scheduler.ts";
38
36
  export { createNodeHost } from "./adapters/node.ts";
@@ -2,7 +2,7 @@
2
2
  // Core runtime
3
3
  export { createUrbanApp } from "./core/runtime.js";
4
4
  // Manifest
5
- export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
5
+ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.js";
6
6
  // Validation
7
7
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
8
8
  // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
@@ -24,9 +24,8 @@ export { defineOperation, mountApi, resolveOperationHandler, NotImplemented } fr
24
24
  export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
25
25
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
26
26
  export { evalCorrelation } from "./core/modules/triggers.js";
27
- // Model deploy + `{{name}}` template substitution
28
- export { deployModels } from "./core/modules/deploy.js";
29
- export { applyTemplates, resolveTemplates } from "./core/modules/templates.js";
27
+ // Model deploy (deploy-by-convention under `resources/`, ADR 0062)
28
+ export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.js";
30
29
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.js";
31
30
  // Adapters + engine + run entrypoint
32
31
  export { createNodeHost } from "./adapters/node.js";
@@ -27,7 +27,6 @@ export async function runFromEnv(opts = {}) {
27
27
  manifestPath: opts.manifestPath,
28
28
  port: opts.port,
29
29
  mount: opts.mount,
30
- templates: opts.templates,
31
30
  });
32
31
  await app.start();
33
32
  if (opts.handleSignals !== false)
@@ -31,6 +31,15 @@ export function createNodeGenIO() {
31
31
  return [];
32
32
  }
33
33
  },
34
+ async listSubdirs(path) {
35
+ try {
36
+ const entries = await readdir(path, { withFileTypes: true });
37
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ },
34
43
  async exists(path) {
35
44
  try {
36
45
  await stat(path);
@@ -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[];
@@ -46,15 +46,63 @@ export async function expandPattern(root, io, pattern) {
46
46
  .map((n) => (dir === "." ? n : `${dir}/${n}`))
47
47
  .sort();
48
48
  }
49
- /** 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
+ */
50
86
  export async function readModels(root, io, manifest) {
51
- const procPatterns = manifest.models?.processes ?? [];
52
- const models = [];
53
- for (const pat of procPatterns) {
54
- for (const rel of await expandPattern(root, io, pat)) {
55
- 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)));
56
96
  }
57
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
+ }
58
106
  return models;
59
107
  }
60
108
  /** Collect all artifacts a run would produce, without touching disk beyond reads. */
@@ -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.48.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
+ });