@nanobpm/nano-workforce 0.138.2 → 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 (44) hide show
  1. package/CHANGELOG.md +12 -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/app/planReadModel.test.ts +23 -0
  15. package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
  16. package/db/migrations/085_delivery_graph_library.sql +32 -0
  17. package/openapi.yaml +366 -0
  18. package/operations/deleteLibraryEntry.test.ts +88 -0
  19. package/operations/deleteLibraryEntry.ts +23 -0
  20. package/operations/dismissProposal.test.ts +105 -0
  21. package/operations/dismissProposal.ts +53 -0
  22. package/operations/getLibraryEntry.test.ts +75 -0
  23. package/operations/getLibraryEntry.ts +25 -0
  24. package/operations/importToLibrary.test.ts +195 -0
  25. package/operations/importToLibrary.ts +62 -0
  26. package/operations/listLibrary.test.ts +79 -0
  27. package/operations/listLibrary.ts +24 -0
  28. package/operations/saveToLibrary.test.ts +225 -0
  29. package/operations/saveToLibrary.ts +89 -0
  30. package/package.json +1 -1
  31. package/pages/delivery-graphs/delivery-graphs.css +33 -0
  32. package/pages/delivery-graphs/embed.html +1 -0
  33. package/pages/delivery-graphs/library-embed.html +31 -0
  34. package/pages/delivery-graphs/library-standalone.html +38 -0
  35. package/pages/delivery-graphs/library.mount.js +364 -0
  36. package/pages/delivery-graphs/mount.js +133 -4
  37. package/pages/delivery-graphs/staged.mount.js +109 -4
  38. package/pages/delivery-graphs/standalone.html +2 -1
  39. package/pages/delivery-graphs.page.json +24 -1
  40. package/scripts/pages-contract.test.ts +50 -0
  41. package/test/delivery-graphs-import.test.ts +92 -0
  42. package/test/delivery-graphs-library-embed.test.ts +148 -0
  43. package/test/delivery-graphs-library-export.test.ts +62 -0
  44. package/test/delivery-graphs-staged-embed.test.ts +9 -0
@@ -0,0 +1,225 @@
1
+ // Tests for POST /app/api/actions/delivery-graph/library/save → `saveToLibrary` (issue #522, epic #519
2
+ // S3). Covers the three required paths: save-from-raw-JSON (`source: composed`), save-from-digest
3
+ // (reuse a staged proposal's stored graph, `source: from-staged`), and validation-reject (an
4
+ // uncompilable graph → clean 400, nothing persisted). Exercised against the REAL SQLite data layer so
5
+ // the migration + store round-trip is validated, not modelled.
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 stageHandler from "./stageDeliveryGraph.ts";
16
+ import handler from "./saveToLibrary.ts";
17
+
18
+ const APP_ROOT = resolve(import.meta.dirname, "..");
19
+
20
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
21
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dglibsave-"));
22
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
23
+ try {
24
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
25
+ await fn(edge, app.db);
26
+ } finally {
27
+ await app.stop?.();
28
+ rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ }
31
+
32
+ async function call(app: AppApi, body: unknown) {
33
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
34
+ }
35
+
36
+ async function stage(app: AppApi, graphJson: string) {
37
+ return (await stageHandler({ req: {} as any, params: {}, query: {}, body: { graphJson } } as any, app)) as any;
38
+ }
39
+
40
+ const GOOD = JSON.stringify({
41
+ name: "runbook",
42
+ nodes: [
43
+ { id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
44
+ { id: "b", kind: "human", human: { prompt: "do X" } },
45
+ ],
46
+ edges: [{ from: "a", to: "b" }],
47
+ });
48
+
49
+ test("save-to-library: a raw graph JSON → 200, saved with source=composed, id derived from name", async () => {
50
+ await withApp(async (app, data) => {
51
+ const res = await call(app, { name: "My Runbook", description: "a note", graphJson: GOOD });
52
+ assertEquals(res.status, 200);
53
+ assertEquals(res.body.ok, true);
54
+ assert(res.body.entry.id.startsWith("my-runbook-"));
55
+ assertEquals(res.body.entry.name, "My Runbook");
56
+ assertEquals(res.body.entry.description, "a note");
57
+ assertEquals(res.body.entry.source, "composed");
58
+ assert(typeof res.body.entry.graph === "string" && res.body.entry.graph.length > 0);
59
+ // Persisted exactly one row.
60
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
61
+ assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.name, "My Runbook");
62
+ });
63
+ });
64
+
65
+ test("save-to-library: from a staged proposal digest → 200, reuses the stored graph, source=from-staged", async () => {
66
+ await withApp(async (app, data) => {
67
+ const staged = await stage(app, GOOD);
68
+ assertEquals(staged.status, 200);
69
+ const digest = staged.body.digest;
70
+ const res = await call(app, { name: "Saved From Staged", digest });
71
+ assertEquals(res.status, 200);
72
+ assertEquals(res.body.ok, true);
73
+ assertEquals(res.body.entry.source, "from-staged");
74
+ // The reused graph compiles to the SAME digest the proposal carried.
75
+ const proposal = await (await import("../app/deliveryGraphProposals.ts")).deliveryGraphProposals(data).get(digest);
76
+ assert(proposal !== undefined);
77
+ // The saved entry reuses the proposal's stored graph verbatim (the digest path), not a recompile:
78
+ // both the response payload and the persisted row carry exactly the proposal's `graph`.
79
+ assertEquals(res.body.entry.graph, proposal.graph);
80
+ assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.graph, proposal.graph);
81
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
82
+ });
83
+ });
84
+
85
+ test("save-to-library: from a dispatched proposal digest → 200, source=from-dispatched", async () => {
86
+ await withApp(async (app, data) => {
87
+ const staged = await stage(app, GOOD);
88
+ assertEquals(staged.status, 200);
89
+ const digest = staged.body.digest;
90
+ const { markProposalDispatched } = await import("../app/deliveryGraphProposals.ts");
91
+ await markProposalDispatched(data, digest);
92
+ const res = await call(app, { name: "Saved From Dispatched", digest });
93
+ assertEquals(res.status, 200);
94
+ assertEquals(res.body.ok, true);
95
+ assertEquals(res.body.entry.source, "from-dispatched");
96
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
97
+ });
98
+ });
99
+
100
+ test("save-to-library: a superseded proposal digest → 400, nothing persisted", async () => {
101
+ await withApp(async (app, data) => {
102
+ const staged = await stage(app, GOOD);
103
+ assertEquals(staged.status, 200);
104
+ const digest = staged.body.digest;
105
+ const proposals = (await import("../app/deliveryGraphProposals.ts")).deliveryGraphProposals(data);
106
+ await proposals.update(digest, { status: "superseded" });
107
+ assertEquals((await proposals.get(digest))?.status, "superseded");
108
+ const res = await call(app, { name: "Saved From Superseded", digest });
109
+ assertEquals(res.status, 400);
110
+ assertEquals(res.body.ok, false);
111
+ assert(typeof res.body.error === "string" && res.body.error.includes("not a live staged/dispatched proposal"));
112
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
113
+ });
114
+ });
115
+
116
+ test("save-to-library: an expired proposal digest → 400, nothing persisted", async () => {
117
+ await withApp(async (app, data) => {
118
+ const staged = await stage(app, GOOD);
119
+ assertEquals(staged.status, 200);
120
+ const digest = staged.body.digest;
121
+ const { markProposalExpired } = await import("../app/deliveryGraphProposals.ts");
122
+ await markProposalExpired(data, digest);
123
+ const res = await call(app, { name: "Saved From Expired", digest });
124
+ assertEquals(res.status, 400);
125
+ assertEquals(res.body.ok, false);
126
+ assert(typeof res.body.error === "string" && res.body.error.includes("not a live staged/dispatched proposal"));
127
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
128
+ });
129
+ });
130
+
131
+ test("save-to-library: a staged-but-TTL-expired proposal digest → 400, nothing persisted", async () => {
132
+ await withApp(async (app, data) => {
133
+ const staged = await stage(app, GOOD);
134
+ assertEquals(staged.status, 200);
135
+ const digest = staged.body.digest;
136
+ // Force the row's TTL horizon into the past while leaving status=`staged`.
137
+ await (await import("../app/deliveryGraphProposals.ts"))
138
+ .deliveryGraphProposals(data)
139
+ .update(digest, { expires_at: new Date(Date.now() - 60_000).toISOString() });
140
+ const res = await call(app, { name: "Saved From Stale Staged", digest });
141
+ assertEquals(res.status, 400);
142
+ assertEquals(res.body.ok, false);
143
+ assert(typeof res.body.error === "string" && res.body.error.includes("not a live staged/dispatched proposal"));
144
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
145
+ });
146
+ });
147
+
148
+ test("save-to-library: an unknown digest → 400, nothing persisted", async () => {
149
+ await withApp(async (app, data) => {
150
+ const res = await call(app, { name: "ghost", digest: "deadbeef0000" });
151
+ assertEquals(res.status, 400);
152
+ assertEquals(res.body.ok, false);
153
+ assert(typeof res.body.error === "string" && res.body.error.includes("no stored graph"));
154
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
155
+ });
156
+ });
157
+
158
+ test("save-to-library: not-valid-JSON graph → 400, nothing persisted", async () => {
159
+ await withApp(async (app, data) => {
160
+ const res = await call(app, { name: "bad", graphJson: "{ not json" });
161
+ assertEquals(res.status, 400);
162
+ assertEquals(res.body.ok, false);
163
+ assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
164
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
165
+ });
166
+ });
167
+
168
+ test("save-to-library: a valid-JSON but UNCOMPILABLE graph → 400 with path-qualified errors, nothing persisted", async () => {
169
+ await withApp(async (app, data) => {
170
+ // A structurally-invalid graph (edge references a node that does not exist) fails compilation.
171
+ const uncompilable = JSON.stringify({
172
+ name: "broken",
173
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }],
174
+ edges: [{ from: "a", to: "missing" }],
175
+ });
176
+ const res = await call(app, { name: "broken", graphJson: uncompilable });
177
+ assertEquals(res.status, 400);
178
+ assertEquals(res.body.ok, false);
179
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
180
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
181
+ });
182
+ });
183
+
184
+ test("save-to-library: a blank name → 400, nothing persisted", async () => {
185
+ await withApp(async (app, data) => {
186
+ const res = await call(app, { name: " ", graphJson: GOOD });
187
+ assertEquals(res.status, 400);
188
+ assertEquals(res.body.ok, false);
189
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
190
+ });
191
+ });
192
+
193
+ test("save-to-library: providing BOTH graphJson and digest → 400, nothing persisted", async () => {
194
+ await withApp(async (app, data) => {
195
+ const res = await call(app, { name: "conflict", graphJson: GOOD, digest: "deadbeef0000" });
196
+ assertEquals(res.status, 400);
197
+ assertEquals(res.body.ok, false);
198
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
199
+ });
200
+ });
201
+
202
+ // Regression (PR #533 review): this door is INTENTIONALLY UNGUARDED, unlike the get/delete/import
203
+ // library doors — it is also invoked by a DECLARATIVE page row action ("Save to library" on the
204
+ // In-flight/History grid) that structurally cannot attach an `x-hook-secret` header. So even when
205
+ // NANO_PR_WEBHOOK_SECRET is configured, a save WITHOUT the header must still succeed (a header guard
206
+ // would 401 the door's own UI). We cache-bust re-import the handler with the secret set to prove it
207
+ // ignores the header entirely.
208
+ test("save-to-library: stays open even when NANO_PR_WEBHOOK_SECRET is set (no header guard)", async () => {
209
+ await withApp(async (app, data) => {
210
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
211
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
212
+ try {
213
+ const mod = await import(`./saveToLibrary.ts?guard=${Date.now()}`);
214
+ const reloaded = mod.default as (c: any, a: any) => Promise<any>;
215
+ // No x-hook-secret header, yet the save is accepted and persisted.
216
+ const res = await reloaded({ req: { headers: new Headers() }, params: {}, query: {}, body: { name: "unguarded", graphJson: GOOD } } as any, app);
217
+ assertEquals(res.status, 200);
218
+ assertEquals(res.body.entry.source, "composed");
219
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
220
+ } finally {
221
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
222
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
223
+ }
224
+ });
225
+ });
@@ -0,0 +1,89 @@
1
+ // POST /app/api/actions/delivery-graph/library/save → operationId `saveToLibrary` (issue #522, epic
2
+ // #519 S3). Persist a delivery graph to the reusable LIBRARY — the durable base S4/S5/S6 build on. The
3
+ // request carries the entry `name` (its slug + short-hash derive the stable library id, so re-saving
4
+ // the same name upserts) plus EITHER a raw `graphJson` STRING (compiled from scratch, `source:
5
+ // composed`) OR the `digest` of an existing staged/dispatched proposal whose ALREADY-STORED graph is
6
+ // reused (`source: from-staged` / `from-dispatched`).
7
+ //
8
+ // Every save validates the graph through the SAME `parseAndCompileText` pipeline the preview/stage
9
+ // doors use, so an uncompilable graph can NEVER be persisted — a bad JSON string or a graph that fails
10
+ // validation is a clean 400 and nothing is written. Mirrors the proposals store/door pattern so the S3
11
+ // API surface is familiar to the S4/S5/S6 slices.
12
+ //
13
+ // This library door is INTENTIONALLY UNGUARDED, unlike the get/delete/import library doors that carry
14
+ // the optional NANO_PR_WEBHOOK_SECRET / x-hook-secret guard. Save is the one library door also reached
15
+ // by a DECLARATIVE page-runtime row action — the "Save to library" action on the In-flight/History grid
16
+ // (`pages/delivery-graphs.page.json`), which posts only `{path, body}` and structurally CANNOT attach a
17
+ // custom `x-hook-secret` header (that affordance is the external `@nanobpm/urban` page runtime's, not
18
+ // ours). A header guard here would therefore make the door unreachable by its own UI (a hard 401 on
19
+ // every dispatched-row Save whenever a secret is configured). The imperative composer/library mounts
20
+ // reach the other doors and can send the header; this door cannot require one until the page runtime
21
+ // grows a supported way for declarative actions to authenticate. See PR #533 review.
22
+
23
+ import {
24
+ buildLibraryEntryRow,
25
+ type DeliveryLibrarySource,
26
+ libraryEntryDto,
27
+ saveLibraryEntry,
28
+ } from "../app/deliveryGraphLibrary.ts";
29
+ import { deliveryGraphProposals, isProposalExpired } from "../app/deliveryGraphProposals.ts";
30
+ import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
31
+ import { defineOperation } from "../nano-generated/operations.ts";
32
+
33
+ export default defineOperation("saveToLibrary", async ({ body }, app) => {
34
+ const name = body && typeof body.name === "string" ? body.name.trim() : "";
35
+ if (name === "") {
36
+ app.log.warn("save-to-library rejected: missing name");
37
+ return { status: 400, body: { ok: false, error: "request body must carry a non-blank `name` for the library entry" } };
38
+ }
39
+ const description = body && typeof body.description === "string" ? body.description : undefined;
40
+
41
+ const digest = body && typeof body.digest === "string" && body.digest.trim() !== "" ? body.digest.trim() : undefined;
42
+ const graphJson = body && typeof body.graphJson === "string" && body.graphJson.trim() !== "" ? body.graphJson : undefined;
43
+ if (digest && graphJson) {
44
+ app.log.warn("save-to-library rejected: both digest and graphJson", { name });
45
+ return { status: 400, body: { ok: false, error: "provide EITHER `graphJson` (a raw graph) OR `digest` (an existing proposal), not both" } };
46
+ }
47
+
48
+ // Resolve the graph text + its provenance. From a digest: reuse the proposal's already-stored graph
49
+ // (still re-validated below, so a corrupt stored graph is refused). From raw JSON: compile from scratch.
50
+ let sourceGraphJson: string;
51
+ let source: DeliveryLibrarySource;
52
+ if (digest) {
53
+ const proposal = await deliveryGraphProposals(app.data).get(digest);
54
+ if (!proposal) {
55
+ app.log.warn("save-to-library rejected: unknown digest", { digest });
56
+ return { status: 400, body: { ok: false, error: `no stored graph for digest ${digest} — stage or dispatch it first, or save a raw graph` } };
57
+ }
58
+ // Only a LIVE staged (not aged out of its TTL) or dispatched proposal may seed the library — a
59
+ // superseded/expired/stale-staged digest is not a canonical graph, and reusing it would both persist a
60
+ // retired graph and mis-label it `source: from-staged`.
61
+ const isLiveStaged = proposal.status === "staged" && !isProposalExpired(proposal.expires_at);
62
+ if (!isLiveStaged && proposal.status !== "dispatched") {
63
+ app.log.warn("save-to-library rejected: digest not live staged/dispatched", { digest, status: proposal.status });
64
+ return { status: 400, body: { ok: false, error: `digest ${digest} is not a live staged/dispatched proposal (status ${proposal.status}) — stage or dispatch it first, or save a raw graph` } };
65
+ }
66
+ sourceGraphJson = proposal.graph;
67
+ source = proposal.status === "dispatched" ? "from-dispatched" : "from-staged";
68
+ } else if (graphJson) {
69
+ sourceGraphJson = graphJson;
70
+ source = "composed";
71
+ } else {
72
+ app.log.warn("save-to-library rejected: no graph source", { name });
73
+ return { status: 400, body: { ok: false, error: "request body must carry either `graphJson` (a raw graph) or `digest` (an existing proposal)" } };
74
+ }
75
+
76
+ // Validate/compile — an uncompilable graph can never enter the library (nothing is persisted here).
77
+ const ingress = await parseAndCompileText({ graphJson: sourceGraphJson });
78
+ if (!ingress.ok) {
79
+ app.log.warn("save-to-library rejected: graph failed validation", { name, source, message: ingress.body.error });
80
+ return { status: 400, body: ingress.body };
81
+ }
82
+
83
+ const saved = await saveLibraryEntry(
84
+ app.data,
85
+ buildLibraryEntryRow({ name, description, graphJson: JSON.stringify(ingress.graph), source }),
86
+ );
87
+ app.log.info("save-to-library saved", { id: saved.id, name: saved.name, source });
88
+ return { status: 200, body: { ok: true, entry: libraryEntryDto(saved) } };
89
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.138.2",
3
+ "version": "0.139.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -170,6 +170,39 @@
170
170
  color: #8aa0b8;
171
171
  }
172
172
 
173
+ /* The filesystem Import control (issue #524): a <label class="btn"> wrapping a visually-hidden native
174
+ <input type=file>, so the file picker reads as a button consistent with the other actions. The label
175
+ dims while its input is disabled (busy) via :has(), mirroring .btn:disabled. */
176
+ .dg .dg-import {
177
+ position: relative;
178
+ display: inline-flex;
179
+ align-items: center;
180
+ }
181
+
182
+ .dg .dg-import-input {
183
+ position: absolute;
184
+ width: 1px;
185
+ height: 1px;
186
+ padding: 0;
187
+ margin: -1px;
188
+ overflow: hidden;
189
+ clip: rect(0, 0, 0, 0);
190
+ border: 0;
191
+ }
192
+
193
+ .dg .dg-import:has(.dg-import-input:disabled) {
194
+ opacity: 0.5;
195
+ cursor: default;
196
+ }
197
+
198
+ /* The native <input type=file> is visually hidden (clipped to 1×1), which also clips its browser
199
+ focus ring, so surface a visible keyboard focus indicator on the wrapping label instead — keyboard
200
+ users can tab to Import and see where focus landed. */
201
+ .dg .dg-import:has(.dg-import-input:focus-visible) {
202
+ outline: 2px solid #58a6ff;
203
+ outline-offset: 2px;
204
+ }
205
+
173
206
  .dg .status {
174
207
  font-size: 12.5px;
175
208
  color: #8aa0b8;
@@ -25,6 +25,7 @@
25
25
  mountDeliveryGraphs(cfg.host ?? document.getElementById("delivery-graphs-root"), {
26
26
  previewUrl: cfg.previewUrl,
27
27
  stageUrl: cfg.stageUrl,
28
+ importUrl: cfg.importUrl,
28
29
  hookSecret: cfg.hookSecret,
29
30
  });
30
31
  </script>
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Delivery graphs — library (reuse · delete) (App View embed)</title>
7
+ <link rel="stylesheet" href="./delivery-graphs.css" />
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0f14; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <!--
14
+ Console App-View embed (ADR 0057, issue #523). The console loads this document into its App-View
15
+ surface and hands it a host element; we mount the SAME reusable-library list (Reuse + Delete) via
16
+ the SAME ./library.mount.js as the standalone shell — only the host and the injected endpoint
17
+ config differ, so the view renders identically. When the console injects endpoint config via
18
+ `window.__NANO_APP_VIEW__`, it wins.
19
+ -->
20
+ <main id="delivery-graphs-library-root"></main>
21
+ <script type="module">
22
+ import { mountDeliveryGraphLibrary } from "./library.mount.js";
23
+
24
+ const cfg = window.__NANO_APP_VIEW__ ?? {};
25
+ mountDeliveryGraphLibrary(cfg.host ?? document.getElementById("delivery-graphs-library-root"), {
26
+ libraryUrl: cfg.libraryUrl,
27
+ hookSecret: cfg.hookSecret,
28
+ });
29
+ </script>
30
+ </body>
31
+ </html>
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Delivery graphs — library (reuse · delete)</title>
7
+ <link rel="stylesheet" href="./delivery-graphs.css" />
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0f14; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <!--
14
+ Standalone shell (phone / direct link). Loads the SAME ./library.mount.js the console App-View
15
+ embed uses, so the standalone and embedded views render identically. Endpoints default to the
16
+ current origin; override the list endpoint via ?library=. For a secured deployment, pass the guard
17
+ secret via the URL fragment #secret= (sent as x-hook-secret) — NOT the query string, so it never
18
+ leaks via server access logs, browser history, or the Referer header. The fragment is stripped
19
+ from the address bar immediately after it is read. Note: "Reuse" fills the compose App-View, which
20
+ only exists in the console — standalone it reports that instead of failing silently.
21
+ -->
22
+ <main id="delivery-graphs-library-root"></main>
23
+ <script type="module">
24
+ import { mountDeliveryGraphLibrary } from "./library.mount.js";
25
+
26
+ const params = new URLSearchParams(location.search);
27
+ const secrets = new URLSearchParams(location.hash.slice(1));
28
+ const hookSecret = secrets.get("secret") ?? undefined;
29
+ if (location.hash) {
30
+ history.replaceState(null, "", location.pathname + location.search);
31
+ }
32
+ mountDeliveryGraphLibrary(document.getElementById("delivery-graphs-library-root"), {
33
+ libraryUrl: params.get("library") ?? undefined,
34
+ hookSecret,
35
+ });
36
+ </script>
37
+ </body>
38
+ </html>