@nanobpm/nano-workforce 0.138.3 → 0.139.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 (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/app/contracts.ts +16 -0
  3. package/app/deliveryGraph.test.ts +84 -0
  4. package/app/deliveryGraph.ts +79 -0
  5. package/app/deliveryGraphCompiler.ts +4 -2
  6. package/app/deliveryGraphLibrary.test.ts +134 -0
  7. package/app/deliveryGraphLibrary.ts +153 -0
  8. package/app/deliveryGraphProposals.test.ts +136 -0
  9. package/app/deliveryGraphProposals.ts +54 -6
  10. package/app/deliveryGraphShape.test.ts +66 -0
  11. package/app/deliveryGraphShape.ts +67 -0
  12. package/app/deliveryGraphTextIngress.test.ts +137 -0
  13. package/app/deliveryGraphTextIngress.ts +73 -3
  14. package/db/migrations/085_delivery_graph_library.sql +32 -0
  15. package/openapi.yaml +366 -0
  16. package/operations/deleteLibraryEntry.test.ts +88 -0
  17. package/operations/deleteLibraryEntry.ts +23 -0
  18. package/operations/dismissProposal.test.ts +105 -0
  19. package/operations/dismissProposal.ts +53 -0
  20. package/operations/getLibraryEntry.test.ts +75 -0
  21. package/operations/getLibraryEntry.ts +25 -0
  22. package/operations/importToLibrary.test.ts +195 -0
  23. package/operations/importToLibrary.ts +62 -0
  24. package/operations/listLibrary.test.ts +79 -0
  25. package/operations/listLibrary.ts +24 -0
  26. package/operations/saveToLibrary.test.ts +225 -0
  27. package/operations/saveToLibrary.ts +89 -0
  28. package/package.json +1 -1
  29. package/pages/delivery-graphs/delivery-graphs.css +33 -0
  30. package/pages/delivery-graphs/embed.html +1 -0
  31. package/pages/delivery-graphs/library-embed.html +31 -0
  32. package/pages/delivery-graphs/library-standalone.html +38 -0
  33. package/pages/delivery-graphs/library.mount.js +364 -0
  34. package/pages/delivery-graphs/mount.js +133 -4
  35. package/pages/delivery-graphs/staged.mount.js +109 -4
  36. package/pages/delivery-graphs/standalone.html +2 -1
  37. package/pages/delivery-graphs.page.json +24 -1
  38. package/scripts/pages-contract.test.ts +50 -0
  39. package/test/delivery-graphs-import.test.ts +92 -0
  40. package/test/delivery-graphs-library-embed.test.ts +148 -0
  41. package/test/delivery-graphs-library-export.test.ts +62 -0
  42. package/test/delivery-graphs-staged-embed.test.ts +9 -0
@@ -0,0 +1,75 @@
1
+ // Tests for GET /app/api/delivery-graph/library/{id} → `getLibraryEntry` (issue #522, epic #519 S3).
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
8
+ import { bootTestApp } from "@nanobpm/urban-testkit";
9
+ import { buildLibraryEntryRow, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
10
+ import { noopLog } from "../test/log.ts";
11
+ import handler from "./getLibraryEntry.ts";
12
+
13
+ const APP_ROOT = resolve(import.meta.dirname, "..");
14
+ const GRAPH = JSON.stringify({ name: "runbook", nodes: [] });
15
+
16
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
17
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dglibget-"));
18
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
19
+ try {
20
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
21
+ await fn(edge, app.db);
22
+ } finally {
23
+ await app.stop?.();
24
+ rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ }
27
+
28
+ const req = { headers: new Headers() } as any;
29
+ async function call(app: AppApi, id: string) {
30
+ return (await handler({ req, params: { id }, query: {} } as any, app)) as any;
31
+ }
32
+
33
+ test("get-library-entry: a known id → 200 with the entry (including graph JSON)", async () => {
34
+ await withApp(async (app, data) => {
35
+ const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
36
+ const res = await call(app, saved.id);
37
+ assertEquals(res.status, 200);
38
+ assertEquals(res.body.id, saved.id);
39
+ assertEquals(res.body.name, "runbook");
40
+ assertEquals(res.body.graph, GRAPH);
41
+ assertEquals(res.body.source, "composed");
42
+ });
43
+ });
44
+
45
+ test("get-library-entry: an unknown id → 404", async () => {
46
+ await withApp(async (app) => {
47
+ const res = await call(app, "no-such-id");
48
+ assertEquals(res.status, 404);
49
+ assert(typeof res.body.error === "string");
50
+ });
51
+ });
52
+
53
+ // The optional shared-secret guard is enforced in the handler (not by OpenAPI `security`), mirroring
54
+ // the other read doors. `SECRET` is captured at module import, so we cache-bust re-import the handler
55
+ // with NANO_PR_WEBHOOK_SECRET set to exercise both the rejected (401) and authorized (200) paths
56
+ // against a real booted data layer holding a known entry.
57
+ test("get-library-entry: shared-secret guard — 401 without x-hook-secret, 200 with it", async () => {
58
+ await withApp(async (app, data) => {
59
+ const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
60
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
61
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
62
+ try {
63
+ const mod = await import(`./getLibraryEntry.ts?guard=${Date.now()}`);
64
+ const guarded = mod.default as (c: any, a: any) => Promise<any>;
65
+ const bad = await guarded({ req: { headers: new Headers() }, params: { id: saved.id }, query: {} } as any, app);
66
+ assertEquals(bad.status, 401);
67
+ const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: { id: saved.id }, query: {} } as any, app);
68
+ assertEquals(ok.status, 200);
69
+ assertEquals(ok.body.id, saved.id);
70
+ } finally {
71
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
72
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
73
+ }
74
+ });
75
+ });
@@ -0,0 +1,25 @@
1
+ // GET /app/api/delivery-graph/library/{id} → operationId `getLibraryEntry` (issue #522, epic #519 S3).
2
+ // Fetch one saved library entry by its `id`, including its full `graph` JSON (the S4 Reuse action loads
3
+ // it into the compose textarea; the S6 export action downloads it). An unknown id is a clean 404.
4
+ //
5
+ // The optional shared-secret guard mirrors the other read doors (getLineage / listActivePrs /
6
+ // listStagedProposals): when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the
7
+ // x-hook-secret header; unset → open.
8
+
9
+ import { getLibraryEntry, libraryEntryDto } from "../app/deliveryGraphLibrary.ts";
10
+ import { envVar } from "../app/version.ts";
11
+ import { defineOperation } from "../nano-generated/operations.ts";
12
+
13
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
14
+
15
+ export default defineOperation("getLibraryEntry", async ({ params, req }, app) => {
16
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
17
+ app.log.warn("getLibraryEntry rejected: missing/invalid shared secret");
18
+ return { status: 401, body: { error: "unauthorized" } };
19
+ }
20
+ const entry = await getLibraryEntry(app.data, params.id);
21
+ if (!entry) {
22
+ return { status: 404, body: { error: `no library entry for id ${params.id}` } };
23
+ }
24
+ return { status: 200, body: libraryEntryDto(entry) };
25
+ });
@@ -0,0 +1,195 @@
1
+ // Tests for POST /app/api/actions/delivery-graph/library/import → `importToLibrary` (issue #524, epic
2
+ // #519 S5). Covers the three required paths: a valid file → 200, saved with source=imported; a file that
3
+ // is not valid JSON → 400, nothing saved; a valid-JSON but UNCOMPILABLE graph → 400 with path-qualified
4
+ // errors, nothing saved. Exercised against the REAL SQLite data layer so the migration + store
5
+ // round-trip is validated, not modelled — mirroring saveToLibrary.test.ts.
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
11
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
12
+ import { bootTestApp } from "@nanobpm/urban-testkit";
13
+ import { deliveryGraphLibrary } from "../app/deliveryGraphLibrary.ts";
14
+ import { noopLog } from "../test/log.ts";
15
+ import handler from "./importToLibrary.ts";
16
+
17
+ const APP_ROOT = resolve(import.meta.dirname, "..");
18
+
19
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
20
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dglibimport-"));
21
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
22
+ try {
23
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
24
+ await fn(edge, app.db);
25
+ } finally {
26
+ await app.stop?.();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ async function call(app: AppApi, body: unknown) {
32
+ // A real empty `Headers` (not `{}`) so the door's shared-secret guard — which reads
33
+ // `req.headers.get("x-hook-secret")` whenever NANO_PR_WEBHOOK_SECRET is configured — is safe to
34
+ // dereference here too, not only in the guard-specific test below.
35
+ return (await handler({ req: { headers: new Headers() } as any, params: {}, query: {}, body } as any, app)) as any;
36
+ }
37
+
38
+ const GOOD = JSON.stringify({
39
+ name: "imported-runbook",
40
+ nodes: [
41
+ { id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
42
+ { id: "b", kind: "human", human: { prompt: "do X" } },
43
+ ],
44
+ edges: [{ from: "a", to: "b" }],
45
+ });
46
+
47
+ test("import-to-library: a valid file → 200, saved with source=imported, name from the graph", async () => {
48
+ await withApp(async (app, data) => {
49
+ const res = await call(app, { graphJson: GOOD });
50
+ assertEquals(res.status, 200);
51
+ assertEquals(res.body.ok, true);
52
+ assertEquals(res.body.entry.source, "imported");
53
+ assertEquals(res.body.entry.name, "imported-runbook");
54
+ assert(res.body.entry.id.startsWith("imported-runbook-"));
55
+ assert(typeof res.body.entry.graph === "string" && res.body.entry.graph.length > 0);
56
+ // Persisted exactly one row.
57
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
58
+ assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.source, "imported");
59
+ });
60
+ });
61
+
62
+ test("import-to-library: an explicit name overrides the graph's own name", async () => {
63
+ await withApp(async (app, data) => {
64
+ const res = await call(app, { graphJson: GOOD, name: "My Import", description: "from disk" });
65
+ assertEquals(res.status, 200);
66
+ assertEquals(res.body.entry.name, "My Import");
67
+ assertEquals(res.body.entry.description, "from disk");
68
+ assert(res.body.entry.id.startsWith("my-import-"));
69
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
70
+ });
71
+ });
72
+
73
+ // The `nameOverride ?? ingress.name` fallback: a graph with NO own name must still import when an
74
+ // explicit override supplies one (the complement of the unnamed-with-no-override → 400 case below).
75
+ test("import-to-library: an unnamed graph imports when an explicit override supplies the name", async () => {
76
+ await withApp(async (app, data) => {
77
+ const unnamed = JSON.stringify({
78
+ nodes: [{ id: "a", kind: "human", human: { prompt: "do X" } }],
79
+ edges: [],
80
+ });
81
+ const res = await call(app, { graphJson: unnamed, name: "Named By Override", description: "from disk" });
82
+ assertEquals(res.status, 200);
83
+ assertEquals(res.body.ok, true);
84
+ assertEquals(res.body.entry.name, "Named By Override");
85
+ assertEquals(res.body.entry.source, "imported");
86
+ assert(res.body.entry.id.startsWith("named-by-override-"));
87
+ // Persisted exactly one row, readable back with source=imported.
88
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
89
+ assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.source, "imported");
90
+ });
91
+ });
92
+
93
+ test("import-to-library: a file that isn't valid JSON → 400, nothing persisted", async () => {
94
+ await withApp(async (app, data) => {
95
+ const res = await call(app, { graphJson: "{ not json" });
96
+ assertEquals(res.status, 400);
97
+ assertEquals(res.body.ok, false);
98
+ assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
99
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
100
+ });
101
+ });
102
+
103
+ test("import-to-library: an empty file → 400, nothing persisted", async () => {
104
+ await withApp(async (app, data) => {
105
+ const res = await call(app, { graphJson: " " });
106
+ assertEquals(res.status, 400);
107
+ assertEquals(res.body.ok, false);
108
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
109
+ });
110
+ });
111
+
112
+ // This is a MUTATING library door, so it carries the same optional shared-secret guard as the
113
+ // get/delete/save library doors. `SECRET` is captured at module import, so we cache-bust re-import the
114
+ // handler with NANO_PR_WEBHOOK_SECRET set to exercise both the rejected (401 — nothing persisted) path
115
+ // and the authorized (200) path against a real booted data layer.
116
+ test("import-to-library: shared-secret guard — 401 without x-hook-secret (no import), 200 with it", async () => {
117
+ await withApp(async (app, data) => {
118
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
119
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
120
+ try {
121
+ const mod = await import(`./importToLibrary.ts?guard=${Date.now()}`);
122
+ const guarded = mod.default as (c: any, a: any) => Promise<any>;
123
+ const bad = await guarded({ req: { headers: new Headers() }, params: {}, query: {}, body: { graphJson: GOOD } } as any, app);
124
+ assertEquals(bad.status, 401);
125
+ // The rejected request must not have persisted anything.
126
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
127
+ const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: {}, query: {}, body: { graphJson: GOOD } } as any, app);
128
+ assertEquals(ok.status, 200);
129
+ assertEquals(ok.body.entry.source, "imported");
130
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
131
+ } finally {
132
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
133
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
134
+ }
135
+ });
136
+ });
137
+
138
+ test("import-to-library: a valid-JSON but UNCOMPILABLE graph → 400 with path-qualified errors, nothing persisted", async () => {
139
+ await withApp(async (app, data) => {
140
+ // A structurally-invalid graph (edge references a node that does not exist) fails compilation.
141
+ const uncompilable = JSON.stringify({
142
+ name: "broken",
143
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }],
144
+ edges: [{ from: "a", to: "missing" }],
145
+ });
146
+ const res = await call(app, { graphJson: uncompilable });
147
+ assertEquals(res.status, 400);
148
+ assertEquals(res.body.ok, false);
149
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
150
+ assert(
151
+ res.body.errors.every(
152
+ (e: { path: string; message: string }) => typeof e.path === "string" && e.path.trim().length > 0,
153
+ ),
154
+ );
155
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
156
+ });
157
+ });
158
+
159
+ // Regression for PR #533 review (operations/importToLibrary.ts): because `graphJson` is a STRING at
160
+ // the request boundary, the runtime's OpenAPI `DeliveryGraph` shape gate never ran on the parsed
161
+ // object, so a malformed NESTED value the semantic validator does not re-enumerate (here
162
+ // `nodes[0].human.prompt: 42`) could return 200 and persist a contract-violating typed field. The
163
+ // reused shape gate now rejects it at the door with a path-qualified error and persists nothing.
164
+ test("import-to-library: a nested-shape violation (human.prompt not a string) → 400, nothing persisted", async () => {
165
+ await withApp(async (app, data) => {
166
+ const malformed = JSON.stringify({
167
+ name: "malformed",
168
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately violating the DeliveryGraph shape.
169
+ nodes: [{ id: "h", kind: "human", human: { prompt: 42 } } as any],
170
+ });
171
+ const res = await call(app, { graphJson: malformed });
172
+ assertEquals(res.status, 400);
173
+ assertEquals(res.body.ok, false);
174
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
175
+ assert(
176
+ res.body.errors.some((e: { path: string; message: string }) => e.path.includes("nodes[0]/human/prompt")),
177
+ "the 400 must carry a path-qualified error at the offending nested field",
178
+ );
179
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
180
+ });
181
+ });
182
+
183
+ test("import-to-library: an unnamed graph with no override → 400, nothing persisted", async () => {
184
+ await withApp(async (app, data) => {
185
+ // A graph with no `name` cannot derive a library id and no override was supplied.
186
+ const unnamed = JSON.stringify({
187
+ nodes: [{ id: "a", kind: "human", human: { prompt: "do X" } }],
188
+ edges: [],
189
+ });
190
+ const res = await call(app, { graphJson: unnamed });
191
+ assertEquals(res.status, 400);
192
+ assertEquals(res.body.ok, false);
193
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
194
+ });
195
+ });
@@ -0,0 +1,62 @@
1
+ // POST /app/api/actions/delivery-graph/library/import → operationId `importToLibrary` (issue #524, epic
2
+ // #519 S5). Import a delivery graph into the reusable LIBRARY from a filesystem FILE. The compose
3
+ // App-View's `<input type=file accept=.json>` reads the chosen file's text client-side and POSTs it here
4
+ // as the raw `graphJson` string.
5
+ //
6
+ // Like `saveToLibrary` (#522), every import validates the graph through the SAME `parseAndCompileText`
7
+ // pipeline the preview/stage doors use, so an uncompilable graph can NEVER be persisted — a file that is
8
+ // not valid JSON, or a graph that fails to compile, is a clean 400 (carrying the path-qualified compile
9
+ // `errors`) and NOTHING is written. The persisted entry is tagged `source: imported`. Its name defaults
10
+ // to the imported graph's own `name`; an explicit `name` in the body overrides it (an unnamed graph with
11
+ // no override is a clean 400 — the library id is name-derived, so a name is required).
12
+ //
13
+ // This is a MUTATING library door, so — like the get/delete library doors — it carries the optional
14
+ // shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret
15
+ // header (the compose client sends it for same-origin requests); unset → open. Without this, a configured
16
+ // deployment would let an unauthenticated caller upsert persistent library entries. (The `save` door is
17
+ // the deliberate exception — it is also reached by a declarative page row action that cannot carry the
18
+ // header, so it is unguarded; see `saveToLibrary.ts`.)
19
+
20
+ import { buildLibraryEntryRow, libraryEntryDto, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
21
+ import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
22
+ import { envVar } from "../app/version.ts";
23
+ import { defineOperation } from "../nano-generated/operations.ts";
24
+
25
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
26
+
27
+ export default defineOperation("importToLibrary", async ({ body, req }, app) => {
28
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
29
+ app.log.warn("import-to-library rejected: missing/invalid shared secret");
30
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
31
+ }
32
+ const graphJson = body && typeof body.graphJson === "string" ? body.graphJson : "";
33
+ if (graphJson.trim() === "") {
34
+ app.log.warn("import-to-library rejected: empty file");
35
+ return { status: 400, body: { ok: false, error: "the imported file was empty — select a delivery-graph `.json` file" } };
36
+ }
37
+ const description = body && typeof body.description === "string" ? body.description : undefined;
38
+ const nameOverride = body && typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : undefined;
39
+
40
+ // Validate/compile — a file that is not valid JSON, or a graph that fails validation, is a clean 400
41
+ // with path-qualified errors and nothing is persisted (an uncompilable graph can never enter the library).
42
+ const ingress = await parseAndCompileText({ graphJson });
43
+ if (!ingress.ok) {
44
+ app.log.warn("import-to-library rejected: graph failed validation", { message: ingress.body.error });
45
+ return { status: 400, body: ingress.body };
46
+ }
47
+
48
+ // The library id is name-derived, so a name is required. It defaults to the imported graph's own
49
+ // compiled `name`; an explicit override wins. An unnamed graph with no override is a clean 400.
50
+ const name = nameOverride ?? ingress.name ?? "";
51
+ if (name.trim() === "") {
52
+ app.log.warn("import-to-library rejected: imported graph has no name and none was provided");
53
+ return { status: 400, body: { ok: false, error: "the imported graph has no `name` — add one to the file, or supply a name" } };
54
+ }
55
+
56
+ const saved = await saveLibraryEntry(
57
+ app.data,
58
+ buildLibraryEntryRow({ name, description, graphJson: JSON.stringify(ingress.graph), source: "imported" }),
59
+ );
60
+ app.log.info("import-to-library saved", { id: saved.id, name: saved.name, source: "imported" });
61
+ return { status: 200, body: { ok: true, entry: libraryEntryDto(saved) } };
62
+ });
@@ -0,0 +1,79 @@
1
+ // Tests for GET /app/api/delivery-graph/library → `listLibrary` (issue #522, epic #519 S3). The read
2
+ // behind the Library App-View: every saved entry, newest first, carrying its full graph JSON.
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
9
+ import { bootTestApp } from "@nanobpm/urban-testkit";
10
+ import { buildLibraryEntryRow, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
11
+ import { noopLog } from "../test/log.ts";
12
+ import handler from "./listLibrary.ts";
13
+
14
+ const APP_ROOT = resolve(import.meta.dirname, "..");
15
+ const GRAPH = JSON.stringify({ name: "runbook", nodes: [] });
16
+
17
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
18
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dgliblist-"));
19
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
20
+ try {
21
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
22
+ await fn(edge, app.db);
23
+ } finally {
24
+ await app.stop?.();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ }
27
+ }
28
+
29
+ const req = { headers: new Headers() } as any;
30
+ async function call(app: AppApi) {
31
+ return (await handler({ req, params: {}, query: {} } as any, app)) as any;
32
+ }
33
+
34
+ test("list-library: empty → count 0", async () => {
35
+ await withApp(async (app) => {
36
+ const res = await call(app);
37
+ assertEquals(res.status, 200);
38
+ assertEquals(res.body.count, 0);
39
+ assertEquals(res.body.entries.length, 0);
40
+ });
41
+ });
42
+
43
+ test("list-library: saved entries returned newest-first with their graph JSON", async () => {
44
+ await withApp(async (app, data) => {
45
+ await saveLibraryEntry(data, buildLibraryEntryRow({ name: "older", graphJson: GRAPH, source: "composed", createdAt: "2024-01-01T00:00:00.000Z" }));
46
+ await saveLibraryEntry(data, buildLibraryEntryRow({ name: "newer", graphJson: GRAPH, source: "imported", createdAt: "2024-06-01T00:00:00.000Z" }));
47
+ const res = await call(app);
48
+ assertEquals(res.status, 200);
49
+ assertEquals(res.body.count, 2);
50
+ assertEquals(res.body.entries[0].name, "newer");
51
+ assertEquals(res.body.entries[1].name, "older");
52
+ assertEquals(res.body.entries[0].graph, GRAPH);
53
+ assertEquals(res.body.entries[0].createdAt, "2024-06-01T00:00:00.000Z");
54
+ assert(typeof res.body.entries[0].updatedAt === "string");
55
+ });
56
+ });
57
+
58
+ // The optional shared-secret guard is enforced in the handler (not by OpenAPI `security`), mirroring
59
+ // the other read doors (getLineage / listActivePrs / listStagedProposals). `SECRET` is captured at
60
+ // module import, so we cache-bust re-import the handler with NANO_PR_WEBHOOK_SECRET set to exercise
61
+ // both the rejected (401) and authorized (200) paths against a real booted data layer.
62
+ test("list-library: shared-secret guard — 401 without x-hook-secret, 200 with it", async () => {
63
+ await withApp(async (app) => {
64
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
65
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
66
+ try {
67
+ const mod = await import(`./listLibrary.ts?guard=${Date.now()}`);
68
+ const guarded = mod.default as (c: any, a: any) => Promise<any>;
69
+ const bad = await guarded({ req: { headers: new Headers() }, params: {}, query: {} } as any, app);
70
+ assertEquals(bad.status, 401);
71
+ const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: {}, query: {} } as any, app);
72
+ assertEquals(ok.status, 200);
73
+ assert(Array.isArray(ok.body.entries));
74
+ } finally {
75
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
76
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
77
+ }
78
+ });
79
+ });
@@ -0,0 +1,24 @@
1
+ // GET /app/api/delivery-graph/library → operationId `listLibrary` (issue #522, epic #519 S3). The read
2
+ // behind the Library App-View (S4/#523): every saved library entry, newest first, with its full
3
+ // `graph` JSON so the export affordance (S6/#525) can build a client-side download straight from the
4
+ // list payload.
5
+ //
6
+ // The optional shared-secret guard mirrors the other read doors (getLineage / listActivePrs /
7
+ // listStagedProposals): when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the
8
+ // x-hook-secret header; unset → open.
9
+
10
+ import { libraryEntryDto, listLibraryEntries } from "../app/deliveryGraphLibrary.ts";
11
+ import { envVar } from "../app/version.ts";
12
+ import { defineOperation } from "../nano-generated/operations.ts";
13
+
14
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
15
+
16
+ export default defineOperation("listLibrary", async ({ req }, app) => {
17
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
18
+ app.log.warn("listLibrary rejected: missing/invalid shared secret");
19
+ return { status: 401, body: { error: "unauthorized" } };
20
+ }
21
+ const rows = await listLibraryEntries(app.data);
22
+ const entries = rows.map(libraryEntryDto);
23
+ return { status: 200, body: { count: entries.length, entries } };
24
+ });