@nanobpm/urban 0.29.0 → 0.30.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 (44) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/core/modules/api.d.ts +61 -0
  4. package/dist/runtime/core/modules/api.js +359 -0
  5. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  6. package/dist/runtime/core/modules/cancel.js +90 -0
  7. package/dist/runtime/core/modules/datasource.d.ts +5 -2
  8. package/dist/runtime/core/modules/datasource.js +5 -4
  9. package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
  10. package/dist/runtime/core/modules/instance-tracking.js +28 -11
  11. package/dist/runtime/core/modules/pages.d.ts +8 -0
  12. package/dist/runtime/core/modules/pages.js +17 -1
  13. package/dist/runtime/core/modules/surfaces.js +9 -0
  14. package/dist/runtime/engine/nanosdk.js +41 -7
  15. package/dist/runtime/index.d.ts +4 -0
  16. package/dist/runtime/index.js +2 -0
  17. package/dist/toolkit/artifact.d.ts +5 -0
  18. package/dist/toolkit/artifact.js +7 -0
  19. package/dist/toolkit/derivers/api.d.ts +19 -0
  20. package/dist/toolkit/derivers/api.js +224 -0
  21. package/dist/toolkit/gen.js +23 -4
  22. package/dist/toolkit/index.d.ts +5 -0
  23. package/dist/toolkit/index.js +12 -1
  24. package/package.json +1 -1
  25. package/src/openapi/spec.test.ts +292 -0
  26. package/src/openapi/spec.ts +540 -0
  27. package/src/runtime/core/modules/api.test.ts +445 -0
  28. package/src/runtime/core/modules/api.ts +453 -0
  29. package/src/runtime/core/modules/cancel.test.ts +310 -0
  30. package/src/runtime/core/modules/cancel.ts +119 -0
  31. package/src/runtime/core/modules/datasource.ts +5 -4
  32. package/src/runtime/core/modules/instance-tracking.ts +33 -11
  33. package/src/runtime/core/modules/pages.test.ts +46 -2
  34. package/src/runtime/core/modules/pages.ts +23 -1
  35. package/src/runtime/core/modules/surfaces.ts +10 -0
  36. package/src/runtime/engine/nanosdk.test.ts +45 -5
  37. package/src/runtime/engine/nanosdk.ts +39 -7
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/artifact.ts +8 -0
  40. package/src/toolkit/derivers/api.test.ts +219 -0
  41. package/src/toolkit/derivers/api.ts +255 -0
  42. package/src/toolkit/gen.test.ts +39 -0
  43. package/src/toolkit/gen.ts +24 -4
  44. package/src/toolkit/index.ts +41 -1
@@ -0,0 +1,445 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { AppApi, RuntimeContext } from "../context.ts";
4
+ import type { AppManifest } from "../manifest.ts";
5
+ import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
6
+ import { makeRouter } from "../router.ts";
7
+ import { DataLayer } from "./datasource.ts";
8
+ import { mountApi, resolveOperationHandler, type OperationHandler } from "./api.ts";
9
+
10
+ function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
11
+ return {
12
+ method,
13
+ path,
14
+ query: new URLSearchParams(opts.query ?? ""),
15
+ headers: new Headers(),
16
+ text: () => Promise.resolve(opts.body ?? ""),
17
+ };
18
+ }
19
+
20
+ const engine: EngineClient = {
21
+ deployResources: async () => ({ deployed: 0 }),
22
+ createInstance: async () => ({ processInstanceKey: "pi" }),
23
+ cancelInstance: async () => {},
24
+ publishMessage: async () => {},
25
+ searchUserTasks: async () => [],
26
+ searchProcessInstances: async () => [],
27
+ completeUserTask: async () => {},
28
+ registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
29
+ close: async () => {},
30
+ };
31
+
32
+ const data = new DataLayer(new Map(), undefined, {});
33
+
34
+ function appFixture(): AppApi {
35
+ return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: () => {} };
36
+ }
37
+
38
+ const spec = JSON.stringify({
39
+ openapi: "3.0.0",
40
+ components: {
41
+ schemas: {
42
+ Invoice: {
43
+ type: "object",
44
+ properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
45
+ required: ["id", "amount"],
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ },
50
+ paths: {
51
+ "/invoices": {
52
+ post: {
53
+ operationId: "createInvoice",
54
+ requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
55
+ responses: { "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } } },
56
+ },
57
+ },
58
+ "/invoices/{id}": {
59
+ get: {
60
+ operationId: "getInvoice",
61
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
62
+ responses: { "200": {} },
63
+ },
64
+ delete: {
65
+ operationId: "rawDelete",
66
+ "x-urban-eject": true,
67
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
68
+ responses: { "204": {} },
69
+ },
70
+ },
71
+ },
72
+ });
73
+
74
+ interface Built {
75
+ router: (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
76
+ imported: string[];
77
+ logs: Array<{ level: string; msg: string }>;
78
+ }
79
+
80
+ function build(
81
+ api: Record<string, unknown> | undefined,
82
+ modules: Record<string, Record<string, unknown>>,
83
+ specText = spec,
84
+ ): Built {
85
+ const imported: string[] = [];
86
+ const logs: Array<{ level: string; msg: string }> = [];
87
+ const host: HostContext = {
88
+ runtime: "node",
89
+ env: () => undefined,
90
+ readTextFile: async () => specText,
91
+ listDir: async () => [],
92
+ exists: async () => false,
93
+ openSqlite: () => {
94
+ throw new Error("sqlite not used in this test");
95
+ },
96
+ importModule: (path: string) => {
97
+ imported.push(path);
98
+ const mod = modules[path];
99
+ if (!mod) return Promise.reject(new Error(`no module at ${path}`));
100
+ return Promise.resolve(mod);
101
+ },
102
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
103
+ now: () => 0,
104
+ log: (level: string, msg: string) => logs.push({ level, msg }),
105
+ };
106
+ const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
107
+ if (api) Reflect.set(manifest, "api", api);
108
+ const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
109
+ const handle = mountApi(ctx, appFixture());
110
+ const router = makeRouter(handle.routes);
111
+ return { router: (r) => Promise.resolve(router(r)), imported, logs };
112
+ }
113
+
114
+ test("resolveOperationHandler prefers default (function) then handler", () => {
115
+ const fn = () => ({});
116
+ assert.equal(resolveOperationHandler({ default: fn }), fn);
117
+ assert.equal(resolveOperationHandler({ handler: fn }), fn);
118
+ assert.equal(resolveOperationHandler({ nope: fn }), undefined);
119
+ });
120
+
121
+ test("no api binding → no routes (no-op for actions-only apps)", () => {
122
+ const { router } = build(undefined, {});
123
+ // makeRouter over an empty route table 404s everything.
124
+ return router(req("GET", "/app/api/invoices/1")).then((res) => assert.equal(res.status, 404));
125
+ });
126
+
127
+ test("routes an operation to its delegate with validated params + body", async () => {
128
+ const seen: unknown[] = [];
129
+ const handler: OperationHandler = (input) => {
130
+ seen.push({ params: input.params, body: input.body });
131
+ return { status: 201, body: { id: "i1", amount: 5 } };
132
+ };
133
+ const { router, imported } = build(
134
+ { spec: "openapi.json", dir: "operations" },
135
+ { "/app/operations/createInvoice": { default: handler } },
136
+ );
137
+ const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ id: "i1", amount: 5 }) }));
138
+ assert.equal(res.status, 201);
139
+ assert.deepEqual(JSON.parse(res.body!), { id: "i1", amount: 5 });
140
+ assert.deepEqual(seen, [{ params: {}, body: { id: "i1", amount: 5 } }]);
141
+ assert.deepEqual(imported, ["/app/operations/createInvoice"]);
142
+ });
143
+
144
+ test("captures path params and passes them to the delegate", async () => {
145
+ let got: Record<string, string> | undefined;
146
+ const { router } = build(
147
+ { spec: "openapi.json" },
148
+ { "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
149
+ );
150
+ const res = await router(req("GET", "/app/api/invoices/42"));
151
+ assert.equal(res.status, 200);
152
+ assert.deepEqual(got, { id: "42" });
153
+ });
154
+
155
+ test("body failing schema validation is a structured 400 before the delegate loads", async () => {
156
+ const { router, imported } = build(
157
+ { spec: "openapi.json" },
158
+ { "/app/operations/createInvoice": { default: () => ({ body: {} }) } },
159
+ );
160
+ const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ amount: 0 }) }));
161
+ assert.equal(res.status, 400);
162
+ const parsed = JSON.parse(res.body!);
163
+ assert.equal(parsed.error, "request validation failed");
164
+ assert.ok(Array.isArray(parsed.issues) && parsed.issues.length >= 1);
165
+ assert.deepEqual(imported, []); // delegate never loaded
166
+ });
167
+
168
+ test("missing required body is a 400", async () => {
169
+ const { router } = build({ spec: "openapi.json" }, { "/app/operations/createInvoice": { default: () => ({}) } });
170
+ const res = await router(req("POST", "/app/api/invoices"));
171
+ assert.equal(res.status, 400);
172
+ assert.match(JSON.parse(res.body!).error, /validation failed/);
173
+ });
174
+
175
+ test("invalid JSON body is a 400 (non-ejected)", async () => {
176
+ const { router } = build({ spec: "openapi.json" }, { "/app/operations/createInvoice": { default: () => ({}) } });
177
+ const res = await router(req("POST", "/app/api/invoices", { body: "{not json" }));
178
+ assert.equal(res.status, 400);
179
+ assert.match(JSON.parse(res.body!).error, /must be JSON/);
180
+ });
181
+
182
+ test("ejected operation skips validation and still routes to its delegate", async () => {
183
+ let called = false;
184
+ const { router } = build(
185
+ { spec: "openapi.json" },
186
+ { "/app/operations/rawDelete": { default: () => { called = true; return { status: 204 }; } } },
187
+ );
188
+ // No validation on an ejected op even though body is not schema-checked.
189
+ const res = await router(req("DELETE", "/app/api/invoices/9", { body: "{not json" }));
190
+ assert.equal(res.status, 204);
191
+ assert.equal(called, true);
192
+ });
193
+
194
+ test("whole-surface eject skips validation for every operation", async () => {
195
+ let seenBody: unknown;
196
+ const { router } = build(
197
+ { spec: "openapi.json", eject: true },
198
+ { "/app/operations/createInvoice": { default: (i: { body: unknown }) => { seenBody = i.body; return { body: { ok: true } }; } } },
199
+ );
200
+ const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ amount: 0 }) }));
201
+ assert.equal(res.status, 200); // amount:0 would fail validation, but eject skips it
202
+ assert.deepEqual(seenBody, { amount: 0 });
203
+ });
204
+
205
+ test("known path on the wrong method is 405; unknown path is 404", async () => {
206
+ const { router } = build({ spec: "openapi.json" }, { "/app/operations/getInvoice": { default: () => ({}) } });
207
+ assert.equal((await router(req("PUT", "/app/api/invoices/1"))).status, 405);
208
+ assert.equal((await router(req("GET", "/app/api/nope"))).status, 404);
209
+ });
210
+
211
+ test("a delegate that fails to load is a 500 on that route", async () => {
212
+ const { router } = build({ spec: "openapi.json" }, {}); // no modules registered
213
+ const res = await router(req("GET", "/app/api/invoices/1"));
214
+ assert.equal(res.status, 500);
215
+ assert.match(JSON.parse(res.body!).error, /delegate failed to load/);
216
+ });
217
+
218
+ test("a malformed spec surfaces as a 500, not a boot failure", async () => {
219
+ const { router } = build({ spec: "openapi.json" }, {}, "{not json");
220
+ const res = await router(req("GET", "/app/api/invoices/1"));
221
+ assert.equal(res.status, 500);
222
+ assert.match(JSON.parse(res.body!).error, /failed to load/);
223
+ });
224
+
225
+ test("a manifest api.base without a leading slash is normalized so routes still match", async () => {
226
+ let got: Record<string, string> | undefined;
227
+ const { router } = build(
228
+ { spec: "openapi.json", base: "app/api" }, // no leading slash
229
+ { "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
230
+ );
231
+ const res = await router(req("GET", "/app/api/invoices/42"));
232
+ assert.equal(res.status, 200);
233
+ assert.deepEqual(got, { id: "42" });
234
+ });
235
+
236
+ test("a malformed spec pattern surfaces as a controlled 500, not a crash", async () => {
237
+ const badPatternSpec = JSON.stringify({
238
+ openapi: "3.0.0",
239
+ paths: {
240
+ "/things": {
241
+ post: {
242
+ operationId: "createThing",
243
+ requestBody: {
244
+ required: true,
245
+ content: {
246
+ "application/json": {
247
+ schema: { type: "object", properties: { name: { type: "string", pattern: "([" } }, required: ["name"] },
248
+ },
249
+ },
250
+ },
251
+ responses: { "200": {} },
252
+ },
253
+ },
254
+ },
255
+ });
256
+ const { router } = build(
257
+ { spec: "openapi.json" },
258
+ { "/app/operations/createThing": { default: () => ({ body: {} }) } },
259
+ badPatternSpec,
260
+ );
261
+ const res = await router(req("POST", "/app/api/things", { body: JSON.stringify({ name: "x" }) }));
262
+ assert.equal(res.status, 500);
263
+ assert.match(JSON.parse(res.body!).error, /invalid pattern/);
264
+ });
265
+
266
+ test("an empty api.dir falls back to the default operations dir, not an absolute path", async () => {
267
+ let called = false;
268
+ const { router, imported } = build(
269
+ { spec: "openapi.json", dir: "" }, // misconfigured empty dir
270
+ { "/app/operations/getInvoice": { default: () => { called = true; return { body: { ok: true } }; } } },
271
+ );
272
+ const res = await router(req("GET", "/app/api/invoices/42"));
273
+ assert.equal(res.status, 200);
274
+ assert.equal(called, true);
275
+ assert.deepEqual(imported, ["/app/operations/getInvoice"]);
276
+ });
277
+
278
+ test("malformed percent-encoding in a path param is a 400, not a 500", async () => {
279
+ const { router } = build(
280
+ { spec: "openapi.json" },
281
+ { "/app/operations/getInvoice": { default: () => ({ body: { ok: true } }) } },
282
+ );
283
+ const res = await router(req("GET", "/app/api/invoices/%E0%A4%A")); // invalid UTF-8 escape
284
+ assert.equal(res.status, 400);
285
+ assert.match(JSON.parse(res.body!).error, /malformed path parameter encoding/);
286
+ });
287
+
288
+ test("repeated query keys are all validated (extra values don't bypass validation)", async () => {
289
+ const arraySpec = JSON.stringify({
290
+ openapi: "3.0.0",
291
+ paths: {
292
+ "/items": {
293
+ get: {
294
+ operationId: "listItems",
295
+ parameters: [{ name: "n", in: "query", schema: { type: "integer", minimum: 1 } }],
296
+ responses: { "200": {} },
297
+ },
298
+ },
299
+ },
300
+ });
301
+ const { router } = build(
302
+ { spec: "openapi.json" },
303
+ { "/app/operations/listItems": { default: () => ({ body: { ok: true } }) } },
304
+ arraySpec,
305
+ );
306
+ // First value valid, second (0) violates minimum:1 — must still be caught.
307
+ const res = await router(req("GET", "/app/api/items", { query: "n=5&n=0" }));
308
+ assert.equal(res.status, 400);
309
+ assert.match(JSON.parse(res.body!).error, /validation failed/);
310
+ });
311
+
312
+ test("array-typed query params validate the whole array (items + array constraints)", async () => {
313
+ const arraySpec = JSON.stringify({
314
+ openapi: "3.0.0",
315
+ paths: {
316
+ "/items": {
317
+ get: {
318
+ operationId: "listItems",
319
+ parameters: [
320
+ { name: "tag", in: "query", schema: { type: "array", minItems: 2, items: { type: "string", enum: ["a", "b"] } } },
321
+ ],
322
+ responses: { "200": {} },
323
+ },
324
+ },
325
+ },
326
+ });
327
+ const build2 = (q: string) =>
328
+ build(
329
+ { spec: "openapi.json" },
330
+ { "/app/operations/listItems": { default: () => ({ body: { ok: true } }) } },
331
+ arraySpec,
332
+ ).router(req("GET", "/app/api/items", { query: q }));
333
+ assert.equal((await build2("tag=a&tag=b")).status, 200); // valid array
334
+ assert.equal((await build2("tag=a")).status, 400); // fewer than minItems
335
+ assert.equal((await build2("tag=a&tag=c")).status, 400); // "c" not in enum
336
+ });
337
+
338
+ test("a failed initial spec load is retried, not cached as a permanent 500", async () => {
339
+ let attempts = 0;
340
+ const host: HostContext = {
341
+ runtime: "node",
342
+ env: () => undefined,
343
+ readTextFile: async () => {
344
+ attempts++;
345
+ if (attempts === 1) throw new Error("ENOENT: spec missing");
346
+ return spec;
347
+ },
348
+ listDir: async () => [],
349
+ exists: async () => false,
350
+ openSqlite: () => {
351
+ throw new Error("sqlite not used in this test");
352
+ },
353
+ importModule: () => Promise.resolve({ default: () => ({ body: { ok: true } }) }),
354
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
355
+ now: () => 0,
356
+ log: () => {},
357
+ };
358
+ const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
359
+ Reflect.set(manifest, "api", { spec: "openapi.json" });
360
+ const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
361
+ const router = makeRouter(mountApi(ctx, appFixture()).routes);
362
+ const first = await Promise.resolve(router(req("GET", "/app/api/invoices/42")));
363
+ assert.equal(first.status, 500); // initial read fails
364
+ const second = await Promise.resolve(router(req("GET", "/app/api/invoices/42")));
365
+ assert.equal(second.status, 200); // retried (opsPromise not cached as a rejection)
366
+ });
367
+
368
+ test("an op declaring no requestBody gets body:undefined at runtime (matches derived body-less type)", async () => {
369
+ // The deriver types `body` as `undefined` for an op with no requestBody, so the runtime must not
370
+ // leak a parsed JSON value into such a body-less handler.
371
+ const noBodySpec = JSON.stringify({
372
+ openapi: "3.0.0",
373
+ paths: {
374
+ "/ping": {
375
+ post: { operationId: "ping", responses: { "200": {} } },
376
+ },
377
+ },
378
+ });
379
+ let seenBody: unknown = "unset";
380
+ const { router } = build(
381
+ { spec: "openapi.json" },
382
+ { "/app/operations/ping": { default: (i: { body: unknown }) => { seenBody = i.body; return { body: { ok: true } }; } } },
383
+ noBodySpec,
384
+ );
385
+ const res = await router(req("POST", "/app/api/ping", { body: JSON.stringify({ sneaky: true }) }));
386
+ assert.equal(res.status, 200);
387
+ assert.equal(seenBody, undefined);
388
+ });
389
+
390
+ test("readApiBinding trims whitespace on base/dir/spec so benign formatting can't break resolution", async () => {
391
+ let got: Record<string, string> | undefined;
392
+ const { router, imported } = build(
393
+ { spec: " openapi.json ", base: " app/api ", dir: " operations " },
394
+ { "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
395
+ );
396
+ const res = await router(req("GET", "/app/api/invoices/42"));
397
+ assert.equal(res.status, 200);
398
+ assert.deepEqual(got, { id: "42" });
399
+ // dir was trimmed → the delegate resolved at the clean path, not "operations /getInvoice".
400
+ assert.ok(imported.includes("/app/operations/getInvoice"));
401
+ });
402
+
403
+ test("loadModule strips a trailing backslash from api.dir (Windows-style path)", async () => {
404
+ const { router, imported } = build(
405
+ { spec: "openapi.json", dir: "operations\\" },
406
+ { "/app/operations/getInvoice": { default: () => ({ body: { ok: true } }) } },
407
+ );
408
+ const res = await router(req("GET", "/app/api/invoices/42"));
409
+ assert.equal(res.status, 200);
410
+ assert.ok(imported.includes("/app/operations/getInvoice"));
411
+ });
412
+
413
+ test("a static route is not shadowed by a templated one regardless of spec order", async () => {
414
+ // Lexicographically "/items/{id}" sorts before "/items/active"; specificity ordering must still
415
+ // route the static path to its own delegate.
416
+ const specText = JSON.stringify({
417
+ openapi: "3.0.0",
418
+ paths: {
419
+ "/items/{id}": { get: { operationId: "getItem", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": {} } } },
420
+ "/items/active": { get: { operationId: "listActive", responses: { "200": {} } } },
421
+ },
422
+ });
423
+ const { router } = build(
424
+ { spec: "openapi.json" },
425
+ {
426
+ "/app/operations/getItem": { default: () => ({ body: { which: "template" } }) },
427
+ "/app/operations/listActive": { default: () => ({ body: { which: "static" } }) },
428
+ },
429
+ specText,
430
+ );
431
+ const res = await router(req("GET", "/app/api/items/active"));
432
+ assert.equal(res.status, 200);
433
+ assert.equal(JSON.parse(res.body ?? "{}").which, "static");
434
+ });
435
+
436
+ test("an unsafe operationId never mounts (skipped) — a crafted spec can't import outside operations", async () => {
437
+ const specText = JSON.stringify({
438
+ openapi: "3.0.0",
439
+ paths: { "/x": { get: { operationId: "../../evil", responses: { "200": {} } } } },
440
+ });
441
+ const { router, imported } = build({ spec: "openapi.json" }, {}, specText);
442
+ const res = await router(req("GET", "/app/api/x"));
443
+ assert.equal(res.status, 404); // no route mounted for the skipped op
444
+ assert.equal(imported.some((p) => p.includes("evil")), false);
445
+ });