@nanobpm/urban 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +4 -2
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +212 -0
  4. package/dist/index.d.ts +5 -0
  5. package/dist/index.js +25 -0
  6. package/dist/runtime/adapters/deno.d.ts +6 -0
  7. package/dist/runtime/adapters/deno.js +98 -0
  8. package/dist/runtime/adapters/detect.d.ts +9 -0
  9. package/dist/runtime/adapters/detect.js +13 -0
  10. package/dist/runtime/adapters/node.d.ts +7 -0
  11. package/dist/runtime/adapters/node.js +113 -0
  12. package/dist/runtime/core/context.d.ts +40 -0
  13. package/dist/runtime/core/context.js +1 -0
  14. package/dist/runtime/core/glob.d.ts +5 -0
  15. package/dist/runtime/core/glob.js +51 -0
  16. package/dist/runtime/core/host.d.ts +128 -0
  17. package/dist/runtime/core/host.js +5 -0
  18. package/dist/runtime/core/manifest.d.ts +83 -0
  19. package/dist/runtime/core/manifest.js +45 -0
  20. package/dist/runtime/core/modules/datasource.d.ts +38 -0
  21. package/dist/runtime/core/modules/datasource.js +172 -0
  22. package/dist/runtime/core/modules/deploy.d.ts +6 -0
  23. package/dist/runtime/core/modules/deploy.js +37 -0
  24. package/dist/runtime/core/modules/security.d.ts +11 -0
  25. package/dist/runtime/core/modules/security.js +32 -0
  26. package/dist/runtime/core/modules/surfaces.d.ts +9 -0
  27. package/dist/runtime/core/modules/surfaces.js +88 -0
  28. package/dist/runtime/core/modules/triggers.d.ts +15 -0
  29. package/dist/runtime/core/modules/triggers.js +119 -0
  30. package/dist/runtime/core/modules/workers.d.ts +16 -0
  31. package/dist/runtime/core/modules/workers.js +79 -0
  32. package/dist/runtime/core/router.d.ts +22 -0
  33. package/dist/runtime/core/router.js +39 -0
  34. package/dist/runtime/core/runtime.d.ts +46 -0
  35. package/dist/runtime/core/runtime.js +173 -0
  36. package/dist/runtime/core/validate.d.ts +13 -0
  37. package/dist/runtime/core/validate.js +108 -0
  38. package/dist/runtime/engine/nanosdk.d.ts +151 -0
  39. package/dist/runtime/engine/nanosdk.js +209 -0
  40. package/dist/runtime/engine/sdk.d.ts +3 -0
  41. package/dist/runtime/engine/sdk.js +13 -0
  42. package/dist/runtime/index.d.ts +21 -0
  43. package/dist/runtime/index.js +17 -0
  44. package/dist/runtime/run.d.ts +17 -0
  45. package/dist/runtime/run.js +69 -0
  46. package/dist/runtime/schema/nano-app.schema.json +471 -0
  47. package/dist/toolkit/artifact.d.ts +19 -0
  48. package/dist/toolkit/artifact.js +11 -0
  49. package/dist/toolkit/derivers/migrations.d.ts +26 -0
  50. package/dist/toolkit/derivers/migrations.js +73 -0
  51. package/dist/toolkit/derivers/worker-io.d.ts +38 -0
  52. package/dist/toolkit/derivers/worker-io.js +135 -0
  53. package/dist/toolkit/fsio.d.ts +2 -0
  54. package/dist/toolkit/fsio.js +35 -0
  55. package/dist/toolkit/gen.d.ts +28 -0
  56. package/dist/toolkit/gen.js +75 -0
  57. package/dist/toolkit/index.d.ts +13 -0
  58. package/dist/toolkit/index.js +15 -0
  59. package/package.json +21 -8
  60. package/src/bin.mjs +6 -11
  61. package/src/cli.ts +14 -1
  62. package/src/index.ts +7 -0
  63. package/src/runtime/core/context.ts +10 -0
  64. package/src/runtime/core/runtime.test.ts +46 -1
  65. package/src/runtime/core/runtime.ts +9 -0
  66. package/src/runtime/engine/nanosdk.test.ts +6 -0
  67. package/src/runtime/engine/nanosdk.ts +10 -0
  68. package/src/runtime/engine/sdk.ts +18 -0
  69. package/src/runtime/index.ts +1 -0
@@ -0,0 +1,15 @@
1
+ import type { AppApi, RuntimeContext } from "../context.ts";
2
+ import { type Route } from "../router.ts";
3
+ export interface TriggersHandle {
4
+ readonly name: string;
5
+ routes: Route[];
6
+ describe(): Record<string, unknown>;
7
+ }
8
+ /** Evaluate a tiny correlation expression: `= body.a.b`, or a literal string. */
9
+ export declare function evalCorrelation(expr: string | undefined, scope: {
10
+ body: unknown;
11
+ headers: Record<string, string>;
12
+ query: Record<string, string>;
13
+ }): string | undefined;
14
+ /** Mount webhook triggers and return their routes. */
15
+ export declare function mountTriggers(ctx: RuntimeContext, app: AppApi): TriggersHandle;
@@ -0,0 +1,119 @@
1
+ // triggers — the inbound I/O edge (ADR 0025). Turns each `webhook` trigger in the manifest
2
+ // into an HTTP route that verifies the request, maps the payload, and publishes the declared
3
+ // message to the engine for correlation. HMAC verification uses Web Crypto (global in both
4
+ // Node 18+ and Deno), so this stays runtime-agnostic.
5
+ import { json, normalizeRoutePath } from "../router.js";
6
+ /** Evaluate a tiny correlation expression: `= body.a.b`, or a literal string. */
7
+ export function evalCorrelation(expr, scope) {
8
+ if (!expr)
9
+ return undefined;
10
+ const trimmed = expr.trim();
11
+ if (!trimmed.startsWith("="))
12
+ return trimmed; // literal
13
+ const pathExpr = trimmed.slice(1).trim(); // e.g. "body.taskId"
14
+ let cur = scope;
15
+ for (const seg of pathExpr.split(".")) {
16
+ if (cur && typeof cur === "object" && seg in cur) {
17
+ cur = cur[seg];
18
+ }
19
+ else {
20
+ return undefined;
21
+ }
22
+ }
23
+ return cur == null ? undefined : String(cur);
24
+ }
25
+ async function verifyHmac(secret, body, signature) {
26
+ const enc = new TextEncoder();
27
+ const key = await globalThis.crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
28
+ const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(body));
29
+ const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
30
+ const provided = signature.replace(/^sha256=/, "").trim().toLowerCase();
31
+ // Constant-time-ish compare.
32
+ if (provided.length !== hex.length)
33
+ return false;
34
+ let diff = 0;
35
+ for (let i = 0; i < hex.length; i++)
36
+ diff |= hex.charCodeAt(i) ^ provided.charCodeAt(i);
37
+ return diff === 0;
38
+ }
39
+ /** Env var holding the secret for an `hmac:<connection>` auth ref. */
40
+ function secretEnvName(connection) {
41
+ return `URBAN_TRIGGER_SECRET_${connection.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
42
+ }
43
+ function headerRecord(req) {
44
+ const out = {};
45
+ req.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
46
+ return out;
47
+ }
48
+ /** Mount webhook triggers and return their routes. */
49
+ export function mountTriggers(ctx, app) {
50
+ const routes = [];
51
+ const mounted = [];
52
+ const seenDeliveries = new Set();
53
+ for (const trig of (ctx.manifest.triggers ?? [])) {
54
+ if (trig.type !== "webhook") {
55
+ ctx.host.log("warn", `trigger "${trig.id}": type "${trig.type}" not implemented, skipped`);
56
+ continue;
57
+ }
58
+ const path = normalizeRoutePath(trig.path, `/hooks/${trig.id}`);
59
+ mounted.push(`${trig.id}@${path}`);
60
+ routes.push({
61
+ method: "POST",
62
+ path,
63
+ source: `trigger:${trig.id}`,
64
+ handler: async (req) => {
65
+ const raw = await req.text();
66
+ const headers = headerRecord(req);
67
+ // Auth (hmac:<connection>).
68
+ if (trig.auth?.startsWith("hmac:")) {
69
+ const conn = trig.auth.slice("hmac:".length);
70
+ const secret = app.env(secretEnvName(conn));
71
+ if (!secret) {
72
+ app.log("error", `trigger "${trig.id}": missing secret ${secretEnvName(conn)}`);
73
+ return json({ error: "server not configured for hmac" }, 500);
74
+ }
75
+ const sig = headers["x-signature"] ?? headers["x-hub-signature-256"] ?? "";
76
+ if (!sig || !(await verifyHmac(secret, raw, sig))) {
77
+ return json({ error: "invalid signature" }, 401);
78
+ }
79
+ }
80
+ // Idempotency by delivery id (bounded set).
81
+ const delivery = headers["x-delivery-id"] ?? headers["x-github-delivery"];
82
+ if (delivery) {
83
+ if (seenDeliveries.has(delivery))
84
+ return json({ ok: true, deduped: true });
85
+ seenDeliveries.add(delivery);
86
+ if (seenDeliveries.size > 10_000) {
87
+ seenDeliveries.delete(seenDeliveries.values().next().value);
88
+ }
89
+ }
90
+ let body = {};
91
+ try {
92
+ body = raw ? JSON.parse(raw) : {};
93
+ }
94
+ catch {
95
+ return json({ error: "body must be JSON" }, 400);
96
+ }
97
+ const message = trig.action?.message;
98
+ if (!message) {
99
+ app.log("warn", `trigger "${trig.id}": no action.message; nothing published`);
100
+ return json({ ok: true, published: false });
101
+ }
102
+ const correlationKey = evalCorrelation(trig.action?.correlationKey, {
103
+ body,
104
+ headers,
105
+ query: Object.fromEntries(req.query),
106
+ });
107
+ await app.engine.publishMessage({
108
+ name: message,
109
+ correlationKey,
110
+ variables: (body && typeof body === "object" ? body : {}),
111
+ });
112
+ app.log("info", `trigger "${trig.id}" published`, { message, correlationKey });
113
+ return json({ ok: true, published: true, message, correlationKey });
114
+ },
115
+ });
116
+ }
117
+ ctx.host.log("info", "triggers mounted", { mounted });
118
+ return { name: "triggers", routes, describe: () => ({ mounted }) };
119
+ }
@@ -0,0 +1,16 @@
1
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
+ import type { EngineJob } from "../host.ts";
3
+ /** A handler as authored by an app: the job plus the injected app API. */
4
+ export type AppJobHandler = (job: EngineJob, app: AppApi) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
5
+ /**
6
+ * Resolve a handler for `jobType` from a loaded module, in priority order:
7
+ * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
8
+ * 2. a named export matching jobType (or its last dotted segment)
9
+ * 3. `default` (when it is a function)
10
+ */
11
+ export declare function resolveHandler(mod: Record<string, unknown>, jobType: string): AppJobHandler | undefined;
12
+ export interface WorkersHandle extends Mounted {
13
+ readonly jobTypes: string[];
14
+ }
15
+ /** Load handler modules and register a push worker per declared worker. */
16
+ export declare function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<WorkersHandle>;
@@ -0,0 +1,79 @@
1
+ // workers — load each declared worker's handler module and register it as a push worker
2
+ // on the engine. Handlers are resolved by a small, documented contract and are injected
3
+ // with the app's runtime API (datasource + engine + host utils).
4
+ import { workerJobType } from "../manifest.js";
5
+ /**
6
+ * Resolve a handler for `jobType` from a loaded module, in priority order:
7
+ * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
8
+ * 2. a named export matching jobType (or its last dotted segment)
9
+ * 3. `default` (when it is a function)
10
+ */
11
+ export function resolveHandler(mod, jobType) {
12
+ const map = mod.handlers;
13
+ if (map && typeof map[jobType] === "function")
14
+ return map[jobType];
15
+ const seg = jobType.includes(".") ? jobType.slice(jobType.lastIndexOf(".") + 1) : jobType;
16
+ if (typeof mod[jobType] === "function")
17
+ return mod[jobType];
18
+ if (map && typeof map[seg] === "function")
19
+ return map[seg];
20
+ if (typeof mod[seg] === "function")
21
+ return mod[seg];
22
+ if (typeof mod.default === "function")
23
+ return mod.default;
24
+ return undefined;
25
+ }
26
+ /** Load handler modules and register a push worker per declared worker. */
27
+ export async function mountWorkers(ctx, app) {
28
+ const decls = ctx.manifest.workers ?? [];
29
+ const subs = [];
30
+ const jobTypes = [];
31
+ // Cache module loads so a multi-type handler module is imported once.
32
+ const moduleCache = new Map();
33
+ const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
34
+ const loadModule = async (path) => {
35
+ const key = joinRoot(path);
36
+ const cached = moduleCache.get(key);
37
+ if (cached)
38
+ return cached;
39
+ const mod = await ctx.host.importModule(key);
40
+ moduleCache.set(key, mod);
41
+ return mod;
42
+ };
43
+ for (const decl of decls) {
44
+ const jobType = workerJobType(decl);
45
+ if (!jobType)
46
+ continue;
47
+ if (!decl.handler) {
48
+ // llm-backed workers (schema `oneOf` handler|llm) are validated but not yet
49
+ // executed by the runtime. Skip with a clear warning rather than crashing.
50
+ ctx.host.log("warn", "skipping worker: llm-backed workers are not yet implemented", {
51
+ jobType,
52
+ llm: decl.llm,
53
+ });
54
+ continue;
55
+ }
56
+ const mod = await loadModule(decl.handler);
57
+ const handler = resolveHandler(mod, jobType);
58
+ if (!handler) {
59
+ throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
60
+ `(expected handlers["${jobType}"], a named export, or a default function)`);
61
+ }
62
+ const wrapped = (job) => handler(job, app);
63
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
64
+ workerName: `${ctx.manifest.id}:${jobType}`,
65
+ });
66
+ subs.push(sub);
67
+ jobTypes.push(jobType);
68
+ ctx.host.log("info", "worker registered", { jobType, handler: decl.handler });
69
+ }
70
+ return {
71
+ name: "workers",
72
+ jobTypes,
73
+ async stop() {
74
+ // Graceful unsubscribe on exit (defensive against the Falcon dead-subscriber stall).
75
+ await Promise.allSettled(subs.map((s) => s.unsubscribe()));
76
+ },
77
+ describe: () => ({ jobTypes }),
78
+ };
79
+ }
@@ -0,0 +1,22 @@
1
+ import type { HttpHandler, HttpResponse } from "./host.ts";
2
+ export interface Route {
3
+ method: string;
4
+ path: string;
5
+ /** When true, match any path that starts with `path`. */
6
+ prefix?: boolean;
7
+ handler: HttpHandler;
8
+ /** For diagnostics / inspect(). */
9
+ source?: string;
10
+ }
11
+ export declare function json(body: unknown, status?: number): HttpResponse;
12
+ export declare function html(body: string, status?: number): HttpResponse;
13
+ export declare function text(body: string, status?: number): HttpResponse;
14
+ /**
15
+ * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
16
+ * slashes. Incoming request paths always start with "/", so a manifest path
17
+ * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
18
+ * `fallback` when the value is missing or collapses to empty ("/").
19
+ */
20
+ export declare function normalizeRoutePath(value: string | undefined, fallback: string): string;
21
+ /** Build a single HttpHandler that dispatches across the given routes. */
22
+ export declare function makeRouter(routes: Route[]): HttpHandler;
@@ -0,0 +1,39 @@
1
+ // A minimal path router shared by the surfaces and triggers modules so the runtime serves
2
+ // a single HTTP port. Matching is exact path or, for a route registered with `prefix`,
3
+ // a path-prefix match. First match wins.
4
+ export function json(body, status = 200) {
5
+ return { status, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
6
+ }
7
+ export function html(body, status = 200) {
8
+ return { status, headers: { "content-type": "text/html; charset=utf-8" }, body };
9
+ }
10
+ export function text(body, status = 200) {
11
+ return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
12
+ }
13
+ /**
14
+ * Normalize a manifest-supplied route path: ensure a leading "/", drop trailing
15
+ * slashes. Incoming request paths always start with "/", so a manifest path
16
+ * lacking one (e.g. "hooks/x") would otherwise never match. Falls back to
17
+ * `fallback` when the value is missing or collapses to empty ("/").
18
+ */
19
+ export function normalizeRoutePath(value, fallback) {
20
+ const raw = (value ?? fallback).trim();
21
+ const withLead = raw.startsWith("/") ? raw : `/${raw}`;
22
+ const noTrail = withLead.replace(/\/+$/, "");
23
+ return noTrail || fallback;
24
+ }
25
+ function matches(route, req) {
26
+ if (route.method !== "*" && route.method.toUpperCase() !== req.method.toUpperCase())
27
+ return false;
28
+ return route.prefix ? req.path.startsWith(route.path) : req.path === route.path;
29
+ }
30
+ /** Build a single HttpHandler that dispatches across the given routes. */
31
+ export function makeRouter(routes) {
32
+ return async (req) => {
33
+ for (const route of routes) {
34
+ if (matches(route, req))
35
+ return route.handler(req);
36
+ }
37
+ return text("not found", 404);
38
+ };
39
+ }
@@ -0,0 +1,46 @@
1
+ import type { EngineClient, HostContext } from "./host.ts";
2
+ import { type AppManifest } from "./manifest.ts";
3
+ import { DataLayer } from "./modules/datasource.ts";
4
+ import { type SecurityPolicy } from "./modules/security.ts";
5
+ /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
6
+ * error when $PORT is set but not a valid integer in 0..65535. */
7
+ export declare function resolvePort(explicit: number | undefined, envPort: string | undefined): number;
8
+ export interface MountFlags {
9
+ deploy?: boolean;
10
+ data?: boolean;
11
+ workers?: boolean;
12
+ surfaces?: boolean;
13
+ triggers?: boolean;
14
+ security?: boolean;
15
+ }
16
+ export interface CreateUrbanAppOptions {
17
+ host: HostContext;
18
+ engine: EngineClient;
19
+ /** App root directory; manifest paths resolve relative to it. Default ".". */
20
+ root?: string;
21
+ /** Provide a manifest object directly, or… */
22
+ manifest?: AppManifest;
23
+ /** …a path to load it from (relative to root). Default "nano.app.json". */
24
+ manifestPath?: string;
25
+ /** HTTP port for surfaces/triggers. Default from PORT env or 8090. */
26
+ port?: number;
27
+ /** Which modules to mount (all true by default). */
28
+ mount?: MountFlags;
29
+ }
30
+ export interface UrbanApp {
31
+ readonly manifest: AppManifest;
32
+ readonly root: string;
33
+ /** Materialize the app (deploy → data → workers → surfaces/triggers). */
34
+ start(): Promise<void>;
35
+ /** Tear everything down cleanly: unsubscribe workers, close the DB, stop the
36
+ * HTTP server, and close the engine client (the app owns the engine's
37
+ * connection lifecycle once started). */
38
+ stop(): Promise<void>;
39
+ /** A structured snapshot of what is mounted. */
40
+ inspect(): Record<string, unknown>;
41
+ /** The provisioned data layer (available after start when `data` is mounted). */
42
+ readonly data: DataLayer | undefined;
43
+ readonly security: SecurityPolicy | undefined;
44
+ readonly httpPort: number | undefined;
45
+ }
46
+ export declare function createUrbanApp(opts: CreateUrbanAppOptions): Promise<UrbanApp>;
@@ -0,0 +1,173 @@
1
+ // The runtime orchestrator: createUrbanApp() takes a manifest + a host + an engine and
2
+ // materializes the app by mounting each module. Hosts (CLI/IDE/console/bare process) call
3
+ // this the same way; a host may mount a subset via `mount` flags (e.g. the console mounts
4
+ // its own surfaces but reuses deploy + workers + datasource).
5
+ import { loadManifest } from "./manifest.js";
6
+ import { validateManifest } from "./validate.js";
7
+ import { makeRouter } from "./router.js";
8
+ import { deployModels } from "./modules/deploy.js";
9
+ import { provisionData, DataLayer } from "./modules/datasource.js";
10
+ import { mountWorkers } from "./modules/workers.js";
11
+ import { mountSurfaces } from "./modules/surfaces.js";
12
+ import { mountTriggers } from "./modules/triggers.js";
13
+ import { mountSecurity } from "./modules/security.js";
14
+ /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
15
+ * error when $PORT is set but not a valid integer in 0..65535. */
16
+ export function resolvePort(explicit, envPort) {
17
+ if (explicit !== undefined)
18
+ return explicit;
19
+ if (envPort === undefined || envPort === "")
20
+ return 8090;
21
+ const n = Number(envPort);
22
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
23
+ throw new Error(`invalid PORT "${envPort}": expected an integer in 0..65535`);
24
+ }
25
+ return n;
26
+ }
27
+ /** Read the underlying nano-sdk client off an engine when it exposes one (the
28
+ * `SdkEngineClient` does). Non-SDK engines (e.g. an in-memory test double) have no
29
+ * `sdk`, so handlers get `undefined` and fall back to the transport-agnostic seam. */
30
+ function engineSdk(engine) {
31
+ return engine.sdk;
32
+ }
33
+ export async function createUrbanApp(opts) {
34
+ const host = opts.host;
35
+ const root = opts.root ?? ".";
36
+ const manifest = validateManifest(opts.manifest ?? (await loadManifest(host, `${root.replace(/\/+$/, "")}/${opts.manifestPath ?? "nano.app.json"}`)));
37
+ const engine = opts.engine;
38
+ const flags = {
39
+ deploy: opts.mount?.deploy ?? true,
40
+ data: opts.mount?.data ?? true,
41
+ workers: opts.mount?.workers ?? true,
42
+ surfaces: opts.mount?.surfaces ?? true,
43
+ triggers: opts.mount?.triggers ?? true,
44
+ security: opts.mount?.security ?? true,
45
+ };
46
+ const ctx = { manifest, host, engine, root };
47
+ let data;
48
+ let security;
49
+ let server;
50
+ const mounted = [];
51
+ const describe = {};
52
+ let started = false;
53
+ let httpPort;
54
+ const port = resolvePort(opts.port, host.env("PORT"));
55
+ // Release every mounted resource and reset internal state so a subsequent
56
+ // start() begins clean. Used by stop() and by start()'s failure path.
57
+ const teardown = async () => {
58
+ if (server) {
59
+ try {
60
+ await server.stop();
61
+ }
62
+ catch (e) {
63
+ host.log("warn", "error stopping http server", { error: String(e) });
64
+ }
65
+ }
66
+ for (const m of mounted) {
67
+ try {
68
+ await m.stop();
69
+ }
70
+ catch (e) {
71
+ host.log("warn", "error stopping module", { error: String(e) });
72
+ }
73
+ }
74
+ if (data)
75
+ data.closeAll();
76
+ try {
77
+ await engine.close();
78
+ }
79
+ catch (e) {
80
+ host.log("warn", "error closing engine", { error: String(e) });
81
+ }
82
+ mounted.length = 0;
83
+ for (const k of Object.keys(describe))
84
+ delete describe[k];
85
+ server = undefined;
86
+ httpPort = undefined;
87
+ data = undefined;
88
+ security = undefined;
89
+ started = false;
90
+ };
91
+ const app = {
92
+ manifest,
93
+ root,
94
+ get data() {
95
+ return data;
96
+ },
97
+ get security() {
98
+ return security;
99
+ },
100
+ get httpPort() {
101
+ return httpPort;
102
+ },
103
+ async start() {
104
+ if (started)
105
+ throw new Error("app already started");
106
+ started = true;
107
+ try {
108
+ if (flags.security)
109
+ security = mountSecurity(ctx);
110
+ if (flags.deploy)
111
+ describe.deploy = await deployModels(ctx);
112
+ data = flags.data ? await provisionData(ctx) : new DataLayer(new Map(), undefined, {});
113
+ const api = {
114
+ manifest,
115
+ data,
116
+ engine,
117
+ sdk: engineSdk(engine),
118
+ env: (n) => host.env(n),
119
+ log: (l, m, f) => host.log(l, m, f),
120
+ };
121
+ if (flags.workers) {
122
+ const w = await mountWorkers(ctx, api);
123
+ mounted.push(w);
124
+ describe.workers = w.describe?.();
125
+ }
126
+ const routes = [];
127
+ if (flags.surfaces) {
128
+ const s = mountSurfaces(ctx, api);
129
+ routes.push(...s.routes);
130
+ describe.surfaces = s.describe();
131
+ }
132
+ if (flags.triggers) {
133
+ const t = mountTriggers(ctx, api);
134
+ routes.push(...t.routes);
135
+ describe.triggers = t.describe();
136
+ }
137
+ if (security)
138
+ describe.security = security.describe();
139
+ if (data)
140
+ describe.data = data.describe();
141
+ if (routes.length > 0) {
142
+ // A tiny liveness route.
143
+ routes.push({
144
+ method: "GET",
145
+ path: "/healthz",
146
+ handler: () => ({
147
+ status: 200,
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify({ ok: true, app: manifest.id }),
150
+ }),
151
+ });
152
+ server = await host.serveHttp(port, makeRouter(routes));
153
+ httpPort = server.port;
154
+ host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
155
+ }
156
+ host.log("info", `urban app "${manifest.id}" started`, {});
157
+ }
158
+ catch (err) {
159
+ // A failed start must not leave the app half-mounted (leaked workers,
160
+ // a bound port) or wedged in the "already started" state.
161
+ await teardown();
162
+ throw err;
163
+ }
164
+ },
165
+ async stop() {
166
+ await teardown();
167
+ },
168
+ inspect() {
169
+ return { app: manifest.id, name: manifest.name, root, httpPort, ...describe };
170
+ },
171
+ };
172
+ return app;
173
+ }
@@ -0,0 +1,13 @@
1
+ import { type AppManifest } from "./manifest.ts";
2
+ export interface ValidationIssue {
3
+ path: string;
4
+ message: string;
5
+ }
6
+ export declare class ManifestValidationError extends Error {
7
+ readonly issues: ValidationIssue[];
8
+ constructor(issues: ValidationIssue[]);
9
+ }
10
+ /** Validate a parsed manifest. Returns the list of issues (empty === valid). */
11
+ export declare function collectManifestIssues(m: unknown): ValidationIssue[];
12
+ /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
13
+ export declare function validateManifest(m: unknown): AppManifest;
@@ -0,0 +1,108 @@
1
+ // Manifest validation. We deliberately avoid a heavyweight JSON-Schema engine here to
2
+ // keep core dependency-free and runtime-agnostic. Instead we drive validation *from* the
3
+ // vendored nano-app.schema.json (the ADR 0027 source of truth) for the top-level envelope
4
+ // — required keys, allowed keys (additionalProperties:false), the schemaVersion const, and
5
+ // the id slug pattern — and then layer the runtime's binding rules (a worker needs a
6
+ // handler, a data source needs driver+url, a typed table needs fields, a trigger needs
7
+ // id+type). Reading the schema means the envelope check tracks the schema and can't drift
8
+ // from it silently.
9
+ import schema from "../schema/nano-app.schema.json" with { type: "json" };
10
+ import { workerJobType } from "./manifest.js";
11
+ export class ManifestValidationError extends Error {
12
+ issues;
13
+ constructor(issues) {
14
+ super(`Invalid Urban manifest (${issues.length} issue${issues.length === 1 ? "" : "s"}):\n` +
15
+ issues.map((i) => ` • ${i.path}: ${i.message}`).join("\n"));
16
+ this.name = "ManifestValidationError";
17
+ this.issues = issues;
18
+ }
19
+ }
20
+ const S = schema;
21
+ /** Validate a parsed manifest. Returns the list of issues (empty === valid). */
22
+ export function collectManifestIssues(m) {
23
+ const issues = [];
24
+ if (m === null || typeof m !== "object" || Array.isArray(m)) {
25
+ return [{ path: "$", message: "manifest must be a JSON object" }];
26
+ }
27
+ const obj = m;
28
+ // — Envelope, driven by the schema —
29
+ for (const req of S.required ?? []) {
30
+ if (!(req in obj))
31
+ issues.push({ path: req, message: "required by nano-app.schema.json" });
32
+ }
33
+ const allowed = new Set(Object.keys(S.properties ?? {}));
34
+ if (S.additionalProperties === false) {
35
+ for (const k of Object.keys(obj)) {
36
+ if (!allowed.has(k)) {
37
+ issues.push({ path: k, message: "unknown top-level key (additionalProperties: false)" });
38
+ }
39
+ }
40
+ }
41
+ if ("schemaVersion" in obj && obj.schemaVersion !== 1) {
42
+ issues.push({ path: "schemaVersion", message: "must be 1" });
43
+ }
44
+ const slugPattern = S.$defs?.slug?.pattern;
45
+ if (slugPattern && typeof obj.id === "string" && !new RegExp(slugPattern).test(obj.id)) {
46
+ issues.push({ path: "id", message: `must match slug pattern ${slugPattern}` });
47
+ }
48
+ if ("name" in obj && (typeof obj.name !== "string" || obj.name.length === 0)) {
49
+ issues.push({ path: "name", message: "must be a non-empty string" });
50
+ }
51
+ // — Runtime binding rules —
52
+ const man = obj;
53
+ if (Array.isArray(man.workers)) {
54
+ man.workers.forEach((w, i) => {
55
+ if (!workerJobType(w)) {
56
+ issues.push({ path: `workers[${i}]`, message: "missing taskType/type" });
57
+ }
58
+ // The schema allows a worker backed by either a `handler` file or an `llm`
59
+ // binding (oneOf). Require exactly one — don't force `handler`.
60
+ const hasHandler = typeof w?.handler === "string" && w.handler.length > 0;
61
+ const hasLlm = typeof w?.llm === "string" && w.llm.length > 0;
62
+ if (!hasHandler && !hasLlm) {
63
+ issues.push({ path: `workers[${i}]`, message: "worker requires a `handler` or `llm`" });
64
+ }
65
+ else if (hasHandler && hasLlm) {
66
+ issues.push({ path: `workers[${i}]`, message: "worker declares both `handler` and `llm` (mutually exclusive)" });
67
+ }
68
+ });
69
+ }
70
+ if (man.data?.sources) {
71
+ for (const [name, src] of Object.entries(man.data.sources)) {
72
+ if (!src || typeof src.driver !== "string") {
73
+ issues.push({ path: `data.sources.${name}.driver`, message: "missing driver" });
74
+ }
75
+ if (!src || typeof src.url !== "string") {
76
+ issues.push({ path: `data.sources.${name}.url`, message: "missing url" });
77
+ }
78
+ }
79
+ if (man.data.default && !man.data.sources[man.data.default]) {
80
+ issues.push({ path: "data.default", message: `no such source "${man.data.default}"` });
81
+ }
82
+ }
83
+ if (man.types) {
84
+ for (const [name, t] of Object.entries(man.types)) {
85
+ // `table` is optional in the schema: a type may declare `fields` without a
86
+ // `table` (a transient / non-persisted domain type). Don't require it.
87
+ if (t && typeof t !== "object") {
88
+ issues.push({ path: `types.${name}`, message: "must be an object" });
89
+ }
90
+ }
91
+ }
92
+ if (Array.isArray(man.triggers)) {
93
+ man.triggers.forEach((t, i) => {
94
+ if (!t?.id)
95
+ issues.push({ path: `triggers[${i}].id`, message: "missing id" });
96
+ if (!t?.type)
97
+ issues.push({ path: `triggers[${i}].type`, message: "missing type" });
98
+ });
99
+ }
100
+ return issues;
101
+ }
102
+ /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
103
+ export function validateManifest(m) {
104
+ const issues = collectManifestIssues(m);
105
+ if (issues.length > 0)
106
+ throw new ManifestValidationError(issues);
107
+ return m;
108
+ }