@nanobpm/nano-workforce 0.138.3 → 0.139.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 (43) 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/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 +374 -0
  34. package/pages/delivery-graphs/mount.js +144 -6
  35. package/pages/delivery-graphs/staged.mount.js +124 -9
  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-embed.test.ts +39 -16
  40. package/test/delivery-graphs-import.test.ts +110 -0
  41. package/test/delivery-graphs-library-embed.test.ts +156 -0
  42. package/test/delivery-graphs-library-export.test.ts +62 -0
  43. package/test/delivery-graphs-staged-embed.test.ts +69 -17
@@ -0,0 +1,374 @@
1
+ // pages/delivery-graphs/library.mount.js — the reusable delivery-graph LIBRARY App-View (ADR 0005,
2
+ // issue #523, epic #519 S4). The OPERATOR surface over the S3 library backend (#522): it LISTS every
3
+ // saved library entry (the `listLibrary` door) and, per row, offers
4
+ // • Reuse — load the saved graph JSON back into the COMPOSE textarea (`#dg-json`) so it can be
5
+ // edited / re-previewed / re-staged. The compose view is a SEPARATE App-View iframe, so Reuse
6
+ // drives the compose mount's inbound fill seam over the host bridge: it posts the shared
7
+ // `deliveryGraph.compose.fill` message (its `type` imported from ./mount.js as the ONE source of
8
+ // truth) UP to the console, which routes it to the compose App-View, which fills `#dg-json`.
9
+ // • Delete — remove the entry via the `deleteLibraryEntry` door (idempotent).
10
+ //
11
+ // A self-contained, dependency-free renderer in the SAME shape as the compose view (./mount.js) and
12
+ // the staged view (./staged.mount.js): the SAME module mounts embedded in the console (App View) and
13
+ // standalone — only the host element and injected endpoint config differ. The app has no browser build
14
+ // step, so this consumes the library doors straight off the wire.
15
+
16
+ import { DG_COMPOSE_FILL_MESSAGE } from "./mount.js";
17
+
18
+ // The read behind the list: every saved library entry, newest first. Anchored to THIS MODULE's url
19
+ // (import.meta.url), NOT the document base. The library App-View shell (library-embed.html /
20
+ // library-standalone.html) — and therefore this mount.js — is served ONE DIRECTORY DEEP at
21
+ // `<appMount>/delivery-graphs/`, while the API is a sibling of that dir at `<appMount>/app/api/…`. A
22
+ // document-base-relative default (`"app/api/delivery-graph/library"`) resolves against the
23
+ // `…/delivery-graphs/` shell base to `…/delivery-graphs/app/api/delivery-graph/library` → 404 on EVERY
24
+ // surface (standalone, local urban-SPA App-View, and the Studio console App-View, which never injects
25
+ // window.__NANO_APP_VIEW__ so this default is what runs). A leading-slash absolute is worse still:
26
+ // through Studio it resolves against the console ORIGIN, not the app-view base (#279). `../app/api/…`
27
+ // off import.meta.url steps up out of `/delivery-graphs/` and lands on `<appMount>/app/api/…` on all
28
+ // surfaces regardless of the document base — the same fix the preview/stage doors ship (#467/#536).
29
+ const DEFAULT_LIBRARY_URL = new URL("../app/api/delivery-graph/library", import.meta.url).href;
30
+
31
+ // How often the list re-polls so a freshly-saved (or just-deleted) entry appears (or drops off)
32
+ // without a manual refresh — mirrors the 5s cadence the staged list uses.
33
+ const DEFAULT_REFRESH_MS = 5000;
34
+
35
+ // A bounded timeout for every door request. Without it a hung door leaves the fetch promise pending
36
+ // forever, so the busy() lock never clears and the UI is stranded; on timeout the AbortController
37
+ // rejects the fetch, surfacing as an error banner and re-enabling the controls via the finally blocks.
38
+ const REQUEST_TIMEOUT_MS = 30000;
39
+
40
+ // The confirm shown before a delete — removing a library entry is a one-way drop (it can only be
41
+ // brought back by re-saving), so the operator acknowledges it.
42
+ const DELETE_CONFIRM = "Delete this saved library entry? It is removed from the library — to bring it back you must save it again.";
43
+
44
+ // The filename suffix for an exported delivery graph (issue #525, epic #519 S6). Export is a purely
45
+ // client-side Blob download of the entry's stored graph JSON — no backend door — so the graph a peer
46
+ // deployment (or a sibling library) later re-imports is byte-identical to what was saved.
47
+ export const DELIVERY_GRAPH_EXPORT_SUFFIX = ".deliverygraph.json";
48
+
49
+ /**
50
+ * Build the client-side download descriptor for exporting a library entry's graph JSON (issue #525).
51
+ * Pure and DOM-free so it is unit-testable in isolation from the mount: it returns the exact
52
+ * { filename, mime, contents } the Export Blob download is assembled from.
53
+ * • `contents` is the entry's STORED graph JSON verbatim (never re-serialised, so a round-trip
54
+ * export→import can't drift the bytes); an entry with no stored graph yields "".
55
+ * • `filename` is the entry name sanitised to a safe basename with the `.deliverygraph.json` suffix,
56
+ * falling back to the entry id (then a constant) when the name is empty or all-unsafe characters.
57
+ * @param {{name?:string, id?:string, graph?:string}} entry
58
+ * @returns {{filename:string, mime:string, contents:string}}
59
+ */
60
+ export function buildDeliveryGraphExport(entry) {
61
+ const contents = entry && typeof entry.graph === "string" ? entry.graph : "";
62
+ const name = String(entry && entry.name != null ? entry.name : "").trim();
63
+ const id = String(entry && entry.id != null ? entry.id : "").trim();
64
+ // Collapse any run of filesystem-unsafe characters to a single hyphen, then trim leading/trailing
65
+ // separators so we never emit a hidden dotfile or a name with dangling punctuation.
66
+ const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
67
+ const stem = safe || (id ? `delivery-graph-${id}` : "delivery-graph");
68
+ return { filename: `${stem}${DELIVERY_GRAPH_EXPORT_SUFFIX}`, mime: "application/json", contents };
69
+ }
70
+
71
+ /** Escape untrusted strings before they touch innerHTML. */
72
+ function esc(value) {
73
+ return String(value ?? "").replace(
74
+ /[&<>"']/g,
75
+ (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch],
76
+ );
77
+ }
78
+
79
+ /** Format an ISO timestamp for the operator, falling back to the raw value if unparseable. */
80
+ function fmtTime(iso) {
81
+ const t = Date.parse(iso);
82
+ if (Number.isNaN(t)) return esc(iso);
83
+ return esc(new Date(t).toLocaleString());
84
+ }
85
+
86
+ /** A small source pill (composed / imported / from-staged / from-dispatched). */
87
+ function sourcePill(source) {
88
+ const s = String(source ?? "").toLowerCase();
89
+ return `<span class="pill pill-${s === "imported" ? "wait" : s === "composed" ? "agent" : "connector"}">${esc(s || "\u2014")}</span>`;
90
+ }
91
+
92
+ /** Render one saved library entry as a card row with Reuse + Delete actions. */
93
+ function renderEntry(entry) {
94
+ const name = entry.name ? `<code>${esc(entry.name)}</code>` : '<span class="muted">(unnamed)</span>';
95
+ const note = entry.description ? `<p class="muted">${esc(entry.description)}</p>` : "";
96
+ return `<section class="card">
97
+ <h2>${name} ${sourcePill(entry.source)}</h2>
98
+ ${note}
99
+ <div class="chips">
100
+ <span class="chip">Saved <b>${fmtTime(entry.createdAt)}</b></span>
101
+ <span class="chip">Updated <b>${fmtTime(entry.updatedAt)}</b></span>
102
+ <span class="chip">Id <code>${esc(entry.id)}</code></span>
103
+ </div>
104
+ <div class="actions">
105
+ <button class="btn btn-primary" type="button" data-reuse="${esc(entry.id)}">Reuse</button>
106
+ <button class="btn btn-ghost" type="button" data-export="${esc(entry.id)}">Export</button>
107
+ <button class="btn btn-ghost" type="button" data-delete="${esc(entry.id)}">Delete</button>
108
+ </div>
109
+ </section>`;
110
+ }
111
+
112
+ /** Render the whole list (or the empty state). */
113
+ function renderList(entries) {
114
+ if (!Array.isArray(entries) || entries.length === 0) {
115
+ return `<section class="card">
116
+ <h2>Library <span class="count">0</span></h2>
117
+ <p class="muted">No saved delivery graphs yet. Save one from the compose or staged views, then Reuse it here.</p>
118
+ </section>`;
119
+ }
120
+ const header = `<section class="card card-ok">
121
+ <h2>Library <span class="count">${entries.length}</span></h2>
122
+ <p class="ok">Saved, reusable delivery graphs. <b>Reuse</b> loads one back into the composer above to edit / re-stage; <b>Export</b> downloads its graph JSON; <b>Delete</b> removes it.</p>
123
+ </section>`;
124
+ return header + entries.map(renderEntry).join("");
125
+ }
126
+
127
+ // Only attach the guard secret when the resolved door URL is SAME-ORIGIN. `libraryUrl` can be
128
+ // overridden (e.g. via the standalone `?library=` query param) to a full `https://…` URL on a foreign
129
+ // origin; sending `x-hook-secret` there would exfiltrate the shared guard secret to an arbitrary host.
130
+ // A cross-origin (or unparseable, or non-browser) target therefore gets no secret.
131
+ function isSameOrigin(url) {
132
+ try {
133
+ if (typeof window === "undefined" || !window.location) return false;
134
+ return new URL(url, window.location.href).origin === window.location.origin;
135
+ } catch (_e) {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Mount the library list into `host`.
142
+ * @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-library-root).
143
+ * @param {{libraryUrl?:string, hookSecret?:string, refreshMs?:number}} [config]
144
+ */
145
+ export function mountDeliveryGraphLibrary(host, config = {}) {
146
+ const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
147
+ const root = isElement ? host : document.getElementById("delivery-graphs-library-root");
148
+ if (!root) return () => {};
149
+
150
+ const libraryUrl = config.libraryUrl ?? DEFAULT_LIBRARY_URL;
151
+ const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
152
+ const headers = (url) => ({
153
+ "content-type": "application/json",
154
+ ...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
155
+ });
156
+
157
+ // The delete door is the per-entry path under the list door: DELETE .../delivery-graph/library/<id>.
158
+ // Append the id to the path only, preserving any query/hash on the configured door so a
159
+ // `?library=` override carrying a search string or fragment still resolves the per-entry URL.
160
+ // Derived by string-appending to `libraryUrl` (no fresh `new URL`) so it inherits the same
161
+ // module-anchored (`../app/api/…`, #467/#536) resolution as the list door it hangs off.
162
+ const entryUrl = (id) => {
163
+ const [beforeHash, hash = ""] = libraryUrl.split("#");
164
+ const [path, search = ""] = beforeHash.split("?");
165
+ return `${path.replace(/\/+$/, "")}/${encodeURIComponent(id)}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
166
+ };
167
+
168
+ root.innerHTML = `<div class="dg">
169
+ <div class="actions">
170
+ <span id="dg-library-status" class="status"></span>
171
+ </div>
172
+ <div id="dg-library-list"></div>
173
+ </div>`;
174
+
175
+ const statusEl = root.querySelector("#dg-library-status");
176
+ const listEl = root.querySelector("#dg-library-list");
177
+
178
+ function setStatus(text, tone) {
179
+ statusEl.textContent = text || "";
180
+ statusEl.className = "status" + (tone ? " status-" + tone : "");
181
+ }
182
+
183
+ // The last loaded entries, kept so Reuse/Delete can resolve an entry (its full `graph` JSON) by id
184
+ // without a second fetch — the list door carries `graph` inline for exactly this.
185
+ let entries = [];
186
+
187
+ let busyCount = 0;
188
+ // A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
189
+ // derived from busyCount and re-applied after each render and on every busy() transition — a poll
190
+ // can't silently re-enable the buttons while a Reuse/Delete request is in flight.
191
+ function applyDisabled() {
192
+ const disabled = busyCount > 0;
193
+ for (const btn of listEl.querySelectorAll("button")) btn.disabled = disabled;
194
+ }
195
+ function busy(on) {
196
+ busyCount += on ? 1 : -1;
197
+ applyDisabled();
198
+ }
199
+
200
+ /** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
201
+ * (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
202
+ async function request(url, init) {
203
+ const controller = new AbortController();
204
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
205
+ try {
206
+ const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
207
+ let body = {};
208
+ try {
209
+ body = await res.json();
210
+ } catch (_e) {
211
+ body = {};
212
+ }
213
+ return { status: res.status, body };
214
+ } finally {
215
+ clearTimeout(timer);
216
+ }
217
+ }
218
+
219
+ const get = (url) => request(url, { method: "GET" });
220
+ const del = (url) => request(url, { method: "DELETE" });
221
+
222
+ let disposed = false;
223
+ // True while the last completed load failed — so a subsequent successful load clears its own stale
224
+ // error banner WITHOUT clobbering a transient action toast (Reuse/Delete ok/err message).
225
+ let loadErrorShown = false;
226
+
227
+ async function refresh() {
228
+ try {
229
+ const { status, body } = await get(libraryUrl);
230
+ if (disposed) return;
231
+ if (status === 200 && Array.isArray(body.entries)) {
232
+ entries = body.entries;
233
+ listEl.innerHTML = renderList(entries);
234
+ applyDisabled();
235
+ if (loadErrorShown) {
236
+ setStatus("");
237
+ loadErrorShown = false;
238
+ }
239
+ } else {
240
+ entries = [];
241
+ listEl.innerHTML = renderList([]);
242
+ applyDisabled();
243
+ setStatus(body && body.error ? body.error : "Could not load the library.", "err");
244
+ loadErrorShown = true;
245
+ }
246
+ } catch (err) {
247
+ if (disposed) return;
248
+ setStatus(err && err.message ? err.message : "Library request failed.", "err");
249
+ loadErrorShown = true;
250
+ }
251
+ }
252
+
253
+ // "Reuse": load a saved graph back into the compose textarea (`#dg-json`). The compose view is a
254
+ // SEPARATE App-View iframe, so we can't touch its DOM directly — we drive its inbound fill seam over
255
+ // the host bridge, posting the shared `deliveryGraph.compose.fill` message UP to the console (the
256
+ // INBOUND twin of the outbound `nano-navigate` DI-preview bridge). Standalone (not embedded) there is
257
+ // no console to route it and no compose view to fill, so we say so instead of failing silently.
258
+ const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
259
+ function doReuse(id) {
260
+ const entry = entries.find((e) => e && e.id === id);
261
+ if (!entry) {
262
+ setStatus("That entry is no longer in the library \u2014 refresh and try again.", "err");
263
+ return;
264
+ }
265
+ if (typeof entry.graph !== "string" || entry.graph.trim() === "") {
266
+ setStatus("That entry has no stored graph to reuse.", "err");
267
+ return;
268
+ }
269
+ if (!isEmbedded) {
270
+ setStatus("Open this page inside the console to reuse a saved graph in the composer.", "err");
271
+ return;
272
+ }
273
+ window.parent.postMessage(
274
+ { type: DG_COMPOSE_FILL_MESSAGE, graphJson: entry.graph },
275
+ window.location.origin,
276
+ );
277
+ setStatus("\u2713 Loaded into the composer above \u2014 edit, Preview or Stage it.", "ok");
278
+ }
279
+
280
+ // "Export": a purely client-side download of a saved entry's graph JSON as `<name>.deliverygraph.json`
281
+ // (issue #525). No backend door — the list door already carries each entry's `graph` inline (the same
282
+ // field Reuse uses), so we assemble a Blob from it and drive an anchor download. The bytes written are
283
+ // the STORED graph verbatim (buildDeliveryGraphExport never re-serialises), so an export→import
284
+ // round-trip is byte-stable. Runs in any context (embedded or standalone) — unlike Reuse it needs no
285
+ // console to route it.
286
+ function doExport(id) {
287
+ const entry = entries.find((e) => e && e.id === id);
288
+ if (!entry) {
289
+ setStatus("That entry is no longer in the library \u2014 refresh and try again.", "err");
290
+ return;
291
+ }
292
+ const { filename, mime, contents } = buildDeliveryGraphExport(entry);
293
+ if (typeof contents !== "string" || contents.trim() === "") {
294
+ setStatus("That entry has no stored graph to export.", "err");
295
+ return;
296
+ }
297
+ let url = null;
298
+ let a = null;
299
+ try {
300
+ const blob = new Blob([contents], { type: mime });
301
+ url = URL.createObjectURL(blob);
302
+ a = document.createElement("a");
303
+ a.href = url;
304
+ a.download = filename;
305
+ document.body.appendChild(a);
306
+ a.click();
307
+ setStatus(`\u2713 Downloaded ${filename}`, "ok");
308
+ } catch (err) {
309
+ setStatus(err && err.message ? err.message : "Export failed.", "err");
310
+ } finally {
311
+ if (a) a.remove();
312
+ // Defer revoke to the next tick so the download can start reliably before the URL is freed.
313
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 0);
314
+ }
315
+ }
316
+
317
+ // "Delete": remove a saved library entry via the deleteLibraryEntry door (idempotent). Confirm (it is
318
+ // a one-way drop off the library), then DELETE the per-entry path; refresh so the row leaves at once.
319
+ async function doDelete(id) {
320
+ if (typeof id !== "string" || id.trim() === "") return;
321
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DELETE_CONFIRM)) {
322
+ return;
323
+ }
324
+ busy(true);
325
+ setStatus("Deleting…");
326
+ try {
327
+ const { status, body } = await del(entryUrl(id));
328
+ if (status === 200 && body.ok) {
329
+ setStatus(body.deleted ? "\u2713 Deleted \u2014 the entry is off the library." : "\u2713 Already gone \u2014 nothing to delete.", "ok");
330
+ await refresh();
331
+ } else {
332
+ setStatus(body && body.error ? body.error : "Delete failed.", "err");
333
+ }
334
+ } catch (err) {
335
+ setStatus(err && err.message ? err.message : "Delete request failed.", "err");
336
+ } finally {
337
+ busy(false);
338
+ }
339
+ }
340
+
341
+ listEl.addEventListener("click", (ev) => {
342
+ const reuseBtn = ev.target && ev.target.closest ? ev.target.closest("[data-reuse]") : null;
343
+ if (reuseBtn) {
344
+ ev.preventDefault();
345
+ doReuse(reuseBtn.getAttribute("data-reuse"));
346
+ return;
347
+ }
348
+ const exportBtn = ev.target && ev.target.closest ? ev.target.closest("[data-export]") : null;
349
+ if (exportBtn) {
350
+ ev.preventDefault();
351
+ doExport(exportBtn.getAttribute("data-export"));
352
+ return;
353
+ }
354
+ const deleteBtn = ev.target && ev.target.closest ? ev.target.closest("[data-delete]") : null;
355
+ if (deleteBtn) {
356
+ ev.preventDefault();
357
+ doDelete(deleteBtn.getAttribute("data-delete"));
358
+ }
359
+ });
360
+
361
+ refresh();
362
+ // Skip a scheduled poll while a Reuse/Delete request is in flight: re-rendering the list mid-request
363
+ // would drop the in-flight button (and its disabled state) out from under the user. The delete path
364
+ // drives its own refresh() on completion, so nothing is missed.
365
+ const timer = setInterval(() => {
366
+ if (busyCount === 0) refresh();
367
+ }, refreshMs);
368
+
369
+ return () => {
370
+ disposed = true;
371
+ clearInterval(timer);
372
+ root.innerHTML = "";
373
+ };
374
+ }
@@ -15,8 +15,40 @@
15
15
  // View) and standalone on a phone — only the host element and injected endpoint config differ. The app
16
16
  // has no browser build step, so this consumes the preview/stage doors straight off the wire.
17
17
 
18
- const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
19
- const DEFAULT_STAGE_URL = "app/api/actions/delivery-graph/stage";
18
+ // Door defaults are anchored to THIS MODULE's url (import.meta.url), NOT the document base. The compose
19
+ // shell (embed.html / standalone.html) — and therefore this mount.js — is served ONE DIRECTORY DEEP at
20
+ // `<appMount>/delivery-graphs/`, while the preview/stage doors are siblings of that dir at
21
+ // `<appMount>/app/api/…`. A document-base-relative default resolves against the `…/delivery-graphs/`
22
+ // shell base → `…/delivery-graphs/app/api/…` → 404 on every surface (the Studio console App-View never
23
+ // injects window.__NANO_APP_VIEW__, so the default is what runs); a leading-slash absolute resolves
24
+ // against the console ORIGIN through Studio (#279). `../app/api/…` off import.meta.url steps up out of
25
+ // `/delivery-graphs/` onto `<appMount>/app/api/…` on all surfaces — the cockpit's #467 fix.
26
+ const DEFAULT_PREVIEW_URL = new URL("../app/api/actions/delivery-graph/preview", import.meta.url).href;
27
+ const DEFAULT_STAGE_URL = new URL("../app/api/actions/delivery-graph/stage", import.meta.url).href;
28
+
29
+ // The filesystem IMPORT door (issue #524, epic #519 S5). The Import control below reads a chosen
30
+ // `.json` file's text client-side and POSTs it here; the door validates + compiles it and persists it
31
+ // to the library with `source: imported`. Module-anchored off import.meta.url like the preview/stage
32
+ // defaults (App-View #279/#467/#536 resolution class — a document-base-relative default 404s under the
33
+ // `…/delivery-graphs/` shell base, a leading-slash absolute resolves against the console origin).
34
+ const DEFAULT_IMPORT_URL = new URL("../app/api/actions/delivery-graph/library/import", import.meta.url).href;
35
+
36
+ // The INBOUND reuse-fill seam (issue #523, epic #519 S4). Until now the compose textarea (`#dg-json`)
37
+ // had NO inbound prefill path — its value was set only by "Load example" or the operator typing. The
38
+ // Library App-View's per-row **Reuse** (this wave) loads a saved graph into the composer by posting a
39
+ // host-bridge message of this shape, which reaches this compose App-View window:
40
+ //
41
+ // { type: DG_COMPOSE_FILL_MESSAGE, graphJson: "<a DeliveryGraph JSON string>" }
42
+ //
43
+ // It is the INBOUND twin of the OUTBOUND `nano-navigate` bridge already used for "Preview generated
44
+ // DI": a small, typed postMessage envelope across the App-View iframe boundary. The shape is declared
45
+ // once in app/contracts.ts as the `deliveryGraph.compose.fill` wire contract, and this string is the
46
+ // ONE source of truth for its `type` — the Library Reuse producer imports it from here rather than
47
+ // re-declaring a synonym. The filesystem **Import** control (#524, sequenced AFTER this) does NOT use
48
+ // this cross-frame message — it lives in THIS same mount, so it fills directly through `fillComposer()`
49
+ // below. Every fill (this message-driven bridge, or a same-mount caller like the #524 file input)
50
+ // routes through the single `fillComposer()` seam below; keep new fill sources going through it.
51
+ export const DG_COMPOSE_FILL_MESSAGE = "nano-delivery-graph-compose-fill";
20
52
 
21
53
  // A bounded timeout for every door request. Without it a hung endpoint leaves the fetch promise pending
22
54
  // forever, so the busy() lock never clears and the UI is stranded (buttons disabled, status stuck) with
@@ -171,10 +203,24 @@ function renderPreview(result, staged) {
171
203
  return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
172
204
  }
173
205
 
206
+ // Only attach the guard secret when the resolved door URL is SAME-ORIGIN. The preview/stage/import door
207
+ // URLs can be overridden (e.g. via the standalone `?preview=` / `?stage=` / `?import=` query params) to a
208
+ // full `https://…`
209
+ // URL on a foreign origin; sending `x-hook-secret` there would exfiltrate the shared guard secret to an
210
+ // arbitrary host. A cross-origin (or unparseable, or non-browser) target therefore gets no secret.
211
+ function isSameOrigin(url) {
212
+ try {
213
+ if (typeof window === "undefined" || !window.location) return false;
214
+ return new URL(url, window.location.href).origin === window.location.origin;
215
+ } catch (_e) {
216
+ return false;
217
+ }
218
+ }
219
+
174
220
  /**
175
221
  * Mount the compose → preview / stage view into `host`.
176
222
  * @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
177
- * @param {{previewUrl?:string, stageUrl?:string, hookSecret?:string}} [config]
223
+ * @param {{previewUrl?:string, stageUrl?:string, importUrl?:string, hookSecret?:string}} [config]
178
224
  */
179
225
  export function mountDeliveryGraphs(host, config = {}) {
180
226
  const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
@@ -183,9 +229,10 @@ export function mountDeliveryGraphs(host, config = {}) {
183
229
 
184
230
  const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
185
231
  const stageUrl = config.stageUrl ?? DEFAULT_STAGE_URL;
186
- const headers = () => ({
232
+ const importUrl = config.importUrl ?? DEFAULT_IMPORT_URL;
233
+ const headers = (url) => ({
187
234
  "content-type": "application/json",
188
- ...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
235
+ ...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
189
236
  });
190
237
 
191
238
  // The static compose shell. The compose card is a native <details> so an operator can COLLAPSE the
@@ -202,6 +249,10 @@ export function mountDeliveryGraphs(host, config = {}) {
202
249
  <button id="dg-preview" class="btn btn-primary" type="button">Preview</button>
203
250
  <button id="dg-stage" class="btn" type="button">Stage</button>
204
251
  <button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
252
+ <label class="btn btn-ghost dg-import" title="Import a delivery-graph .json file into the library">
253
+ Import file
254
+ <input id="dg-import" class="dg-import-input" type="file" accept=".json,application/json" />
255
+ </label>
205
256
  <span id="dg-status" class="status"></span>
206
257
  </div>
207
258
  </div>
@@ -215,6 +266,8 @@ export function mountDeliveryGraphs(host, config = {}) {
215
266
  const previewBtn = root.querySelector("#dg-preview");
216
267
  const stageBtn = root.querySelector("#dg-stage");
217
268
  const exampleBtn = root.querySelector("#dg-example");
269
+ const importInput = root.querySelector("#dg-import");
270
+ const composeDetails = root.querySelector("#dg-compose");
218
271
 
219
272
  // The most recent successful PREVIEW's laid-out BPMN — bridged to the host explorer on demand (the
220
273
  // preview door returns it, so DI preview needs no staging, #516). Cleared whenever the composed graph
@@ -230,6 +283,51 @@ export function mountDeliveryGraphs(host, config = {}) {
230
283
  previewBtn.disabled = on;
231
284
  stageBtn.disabled = on;
232
285
  exampleBtn.disabled = on;
286
+ if (importInput) importInput.disabled = on;
287
+ // Lock the textarea too: an in-flight import awaits file.text() + the POST, and on success
288
+ // fillComposer() overwrites #dg-json unconditionally — leaving it editable would let a slow import
289
+ // silently clobber edits the operator made during the delay.
290
+ jsonEl.disabled = on;
291
+ }
292
+
293
+ // The SINGLE inbound fill seam (issue #523): load a graph JSON into the composer as if the operator
294
+ // had pasted it. It is driven by the Library Reuse host-bridge message (below) and — same-mount, no
295
+ // bridge — by the #524 filesystem import that lands after this. It resets the previewed BPMN (so
296
+ // "Preview generated DI" can never show a stale diagram against freshly-filled JSON), clears the old
297
+ // output, and expands the (possibly collapsed) compose panel so the loaded graph is visible. Returns
298
+ // true when it filled, false for a blank/non-string payload (nothing is clobbered on a bad fill).
299
+ function fillComposer(graphJson, opts = {}) {
300
+ if (typeof graphJson !== "string" || graphJson.trim() === "") return false;
301
+ jsonEl.value = graphJson;
302
+ lastBpmn = "";
303
+ outputEl.innerHTML = "";
304
+ if (composeDetails && !composeDetails.open) composeDetails.open = true;
305
+ setStatus(opts.status || "Loaded into the composer \u2014 Preview or Stage it.", "ok");
306
+ return true;
307
+ }
308
+
309
+ // The inbound half of the App-View bridge: fill the composer from a Library Reuse (or import)
310
+ // message. Only a SAME-ORIGIN message of the agreed `deliveryGraph.compose.fill` shape fills — a
311
+ // foreign origin or a mismatched shape is ignored, so this listener can't be driven by an unrelated
312
+ // page. Registered on `window` (the message arrives on this App-View's own window) and torn down by
313
+ // the disposer below.
314
+ function onFillMessage(ev) {
315
+ if (!ev || typeof window === "undefined") return;
316
+ // Same-origin host-bridge seam: require an EXACT origin match. A missing/empty/foreign `ev.origin`
317
+ // (a malformed or forged event, or a future browser edge case) is rejected outright — never fall
318
+ // through to the fill just because the origin was absent.
319
+ if (ev.origin !== window.location.origin) return;
320
+ // The fill message is routed UP to the console and back down when embedded, so accept it only from
321
+ // the parent frame in that case — a missing/falsy `ev.source` is rejected too, never allowed to fall
322
+ // through; standalone, there is no parent and the import path fills directly.
323
+ const embedded = typeof window.parent !== "undefined" && window.parent !== window;
324
+ if (embedded && ev.source !== window.parent) return;
325
+ const data = ev.data;
326
+ if (!data || data.type !== DG_COMPOSE_FILL_MESSAGE || typeof data.graphJson !== "string") return;
327
+ fillComposer(data.graphJson, { status: "Reused a saved graph \u2014 Preview or Stage it." });
328
+ }
329
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
330
+ window.addEventListener("message", onFillMessage);
233
331
  }
234
332
 
235
333
  /** POST a JSON body to a door and return { status, body } (never throws on an HTTP error). Rejects
@@ -240,7 +338,7 @@ export function mountDeliveryGraphs(host, config = {}) {
240
338
  try {
241
339
  const res = await fetch(url, {
242
340
  method: "POST",
243
- headers: headers(),
341
+ headers: headers(url),
244
342
  body: JSON.stringify(payload),
245
343
  signal: controller.signal,
246
344
  });
@@ -299,6 +397,43 @@ export function mountDeliveryGraphs(host, config = {}) {
299
397
  outputEl.innerHTML = "";
300
398
  setStatus("Example loaded — Preview or Stage it.", "");
301
399
  });
400
+
401
+ // The filesystem IMPORT control (issue #524, epic #519 S5). Read the chosen `.json` file's text
402
+ // CLIENT-SIDE, then POST it to the importToLibrary door, which validates + compiles it and (on
403
+ // success) persists it to the library with `source: imported`. A file that is not valid JSON, or a
404
+ // graph that will not compile, is a clean 400 whose path-qualified errors render inline — nothing is
405
+ // persisted. On a successful import we route the file text through the SAME `fillComposer()` seam
406
+ // #523 introduced (no reshaping — one inbound fill path), so the imported graph appears in the
407
+ // composer ready to Preview/Stage. The input is reset after each pick so re-choosing the same file
408
+ // still fires `change`.
409
+ async function importFile(file) {
410
+ if (!file) return;
411
+ busy(true);
412
+ setStatus(`Importing ${file.name}\u2026`);
413
+ try {
414
+ const text = await file.text();
415
+ const { status, body } = await post(importUrl, { graphJson: text });
416
+ if (status === 200 && body.ok) {
417
+ outputEl.innerHTML = "";
418
+ fillComposer(text, { status: `\u2713 Imported \u201c${body.entry.name}\u201d into the library — Preview or Stage it.` });
419
+ } else {
420
+ outputEl.innerHTML = renderErrors(body.error, body.errors);
421
+ setStatus("Import failed — see the details below.", "err");
422
+ }
423
+ } catch (err) {
424
+ outputEl.innerHTML = renderErrors(err && err.message ? err.message : String(err), []);
425
+ setStatus("Import request failed.", "err");
426
+ } finally {
427
+ busy(false);
428
+ }
429
+ }
430
+ if (importInput) {
431
+ importInput.addEventListener("change", () => {
432
+ const file = importInput.files && importInput.files[0];
433
+ importFile(file);
434
+ importInput.value = "";
435
+ });
436
+ }
302
437
  // Any edit invalidates the previewed BPMN so "Preview generated DI" can't show a stale diagram.
303
438
  jsonEl.addEventListener("input", () => {
304
439
  lastBpmn = "";
@@ -334,6 +469,9 @@ export function mountDeliveryGraphs(host, config = {}) {
334
469
  });
335
470
 
336
471
  return () => {
472
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
473
+ window.removeEventListener("message", onFillMessage);
474
+ }
337
475
  root.innerHTML = "";
338
476
  };
339
477
  }