@nanobpm/urban 0.88.0 → 0.88.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.88.0",
3
+ "version": "0.88.1",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -175,6 +175,74 @@ function installThemeBridge() {
175
175
  window.parent.postMessage({ type: "nano-app-ready" }, window.location.origin);
176
176
  }
177
177
 
178
+ // ── App-View message relay (#518) ─────────────────────────────────────────
179
+ // The App-View iframes this page currently mounts (renderAppView adds each one;
180
+ // teardown removes it so a switched-away page's frames leave the relay set). The
181
+ // bridge below uses this set as the trust membership: a message is only relayed
182
+ // if ev.source is one of THESE frames' contentWindows (a nested App-View of this
183
+ // page), never an arbitrary framed window.
184
+ /** @type {Set<HTMLIFrameElement>} */
185
+ const APP_VIEW_FRAMES = new Set();
186
+
187
+ // The relay primitive. A nested App-View posts to `window.parent` (this page),
188
+ // but this page installs no bridge, so the message is dropped: it never reaches
189
+ // the console host (forward-UP) nor the page's other App-Views (relay-ACROSS).
190
+ // This forwards a whitelisted `nano-navigate` UP to the host (chaining the same
191
+ // hostNavigate boundary the page's own links use, so a nested App-View can drive
192
+ // the console explorer — Preview DI, instance deep-links) and relays any other
193
+ // App-View message ACROSS to the page's sibling App-Views (e.g. Library "Reuse"
194
+ // → Compose fill). Deliberately generic (forward-up + broadcast-across), not a
195
+ // per-message special case, so the whole cross-frame failure class is closed at
196
+ // once rather than patched button-by-button.
197
+ //
198
+ // Trust boundary: the message must be same-origin AND from a known App-View
199
+ // iframe of THIS page. `ev.source` alone is insufficient — the origin pin plus
200
+ // the membership check are what actually gate the boundary, so a message from a
201
+ // non-App-View source or a cross-origin frame is never relayed.
202
+ /**
203
+ * @param {{ origin: string, source: any, data: any }} ev
204
+ * @param {Iterable<{ contentWindow: any }>} frames
205
+ * @param {{ origin: string, parent: any, embedded: boolean }} ctx
206
+ */
207
+ function relayAppViewMessage(ev, frames, ctx) {
208
+ if (ev.origin !== ctx.origin) return;
209
+ const list = Array.isArray(frames) ? frames : [...frames];
210
+ let originFrame = null;
211
+ for (const f of list) {
212
+ if (f.contentWindow && f.contentWindow === ev.source) { originFrame = f; break; }
213
+ }
214
+ // Unknown source: not one of this page's App-Views — reject (trust boundary).
215
+ if (!originFrame) return;
216
+ const data = ev.data;
217
+ if (data && data.type === "nano-navigate") {
218
+ // Forward upward to the host/console, gated on the same same-origin embed
219
+ // boundary as hostNavigate: standalone (no same-origin console parent) there
220
+ // is nothing to forward to, so the message stays within the page.
221
+ if (ctx.embedded && ctx.parent) ctx.parent.postMessage(data, ctx.origin);
222
+ return;
223
+ }
224
+ // Relay across to the page's OTHER App-Views (never back to the originator).
225
+ for (const f of list) {
226
+ if (f === originFrame) continue;
227
+ const w = f.contentWindow;
228
+ if (w) w.postMessage(data, ctx.origin);
229
+ }
230
+ }
231
+
232
+ // Install the App-View relay as a single page-level `message` listener. Unlike
233
+ // the theme bridge this is NOT gated on NANO_EMBEDDED: the sibling relay-across
234
+ // works within a standalone page too; only the forward-up leg is embed-gated
235
+ // (via ctx.embedded in relayAppViewMessage).
236
+ function installAppViewBridge() {
237
+ window.addEventListener("message", (ev) => {
238
+ relayAppViewMessage(ev, APP_VIEW_FRAMES, {
239
+ origin: window.location.origin,
240
+ parent: window.parent,
241
+ embedded: NANO_EMBEDDED,
242
+ });
243
+ });
244
+ }
245
+
178
246
  // Resolve every app endpoint against where THIS module was actually served from,
179
247
  // so absolute-looking "/app/…" paths work both at the origin root (direct run,
180
248
  // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
@@ -2769,6 +2837,13 @@ function renderAppView(node) {
2769
2837
  });
2770
2838
 
2771
2839
  const section = el("section", { class: "pc-appview" + (fill ? " pc-appview-fill" : "") });
2840
+ // Register this frame in the App-View relay set (#518) so the page-level bridge
2841
+ // can (a) recognise its outbound postMessages as coming from a known App-View of
2842
+ // this page and (b) address it as a sibling target for another App-View's relay.
2843
+ // A teardown disposer drops it again, so a switched-away page's frames don't
2844
+ // linger as phantom relay endpoints.
2845
+ APP_VIEW_FRAMES.add(frame);
2846
+ disposers.push(() => APP_VIEW_FRAMES.delete(frame));
2772
2847
  // The title is an <h2> (the tag EVERY card renderer uses and makeCollapsible lifts into the
2773
2848
  // collapse header), not a <div>: a collapsible appView's title must be lifted out of the body,
2774
2849
  // otherwise it double-renders (header label + leftover in-body title). The .pc-appview-title CSS
@@ -2934,6 +3009,7 @@ function boot() {
2934
3009
  PARAM = parseRoute().param;
2935
3010
  NANO_EMBEDDED = computeEmbedded();
2936
3011
  installThemeBridge();
3012
+ installAppViewBridge();
2937
3013
  window.addEventListener("hashchange", renderPage);
2938
3014
  // Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'
2939
3015
  // variant swaps in/out on rotation or a window resize (the grid card flip, nav
@@ -2953,4 +3029,4 @@ function boot() {
2953
3029
  // renderer functions / RENDERERS registry can be imported directly.
2954
3030
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2955
3031
 
2956
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, buildEngineForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
3032
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, relayAppViewMessage, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, buildEngineForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
@@ -7,7 +7,7 @@
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import { PAGE_NODE_TYPES } from "../core/page-nodes.ts";
10
- import { RENDERERS, renderText, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
10
+ import { RENDERERS, renderText, renderAppView, relayAppViewMessage, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
11
11
 
12
12
  // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
13
  // Just enough of the Element/Document surface that el()/renderText touch:
@@ -574,3 +574,128 @@ test("#338: a param-scoped nav badge with no route param degrades to 0 without q
574
574
  assert.equal(pill.textContent, "0", "badge degrades to 0 rather than an empty-string-filtered count");
575
575
  assert.equal(pill.hidden, false, "0 is shown (hideWhenZero is off) rather than a stale count");
576
576
  });
577
+
578
+ // ── #518: App-View message relay (host-up + sibling-across bridge) ───────────
579
+ // A nested App-View posts to `window.parent` (the Urban page). Before this fix
580
+ // the page installed no bridge for those inbound postMessages, so a
581
+ // `nano-navigate` never chained up to the console host (DI Preview did nothing)
582
+ // and a sibling-directed message never reached the page's other App-Views
583
+ // (Library "Reuse" filled nothing). relayAppViewMessage is the primitive that
584
+ // closes that whole class: forward-up for nano-navigate, broadcast-across for
585
+ // everything else, both behind a strict same-origin + known-App-View-source
586
+ // trust boundary. These drive the primitive directly with fake frame windows.
587
+
588
+ // A minimal Window stand-in that records its postMessage calls, used both as an
589
+ // App-View iframe's contentWindow and as the host `window.parent`.
590
+ function fakeWindow(): { posts: Array<{ data: unknown; origin: string }> } & {
591
+ postMessage(data: unknown, origin: string): void;
592
+ } {
593
+ const posts: Array<{ data: unknown; origin: string }> = [];
594
+ return {
595
+ posts,
596
+ postMessage(data: unknown, origin: string): void {
597
+ posts.push({ data, origin });
598
+ },
599
+ };
600
+ }
601
+
602
+ // An App-View iframe stand-in exposing just the `.contentWindow` the relay reads.
603
+ function fakeFrame(win: unknown): { contentWindow: unknown } {
604
+ return { contentWindow: win };
605
+ }
606
+
607
+ const ORIGIN = "https://app.test";
608
+
609
+ test("#518: relayAppViewMessage forwards a child App-View's nano-navigate UP to the host", () => {
610
+ const parent = fakeWindow();
611
+ const composeWin = fakeWindow();
612
+ const compose = fakeFrame(composeWin);
613
+ const frames = [compose];
614
+ const data = { type: "nano-navigate", target: "definitionPreview", params: { xml: "<x/>" } };
615
+
616
+ relayAppViewMessage(
617
+ { origin: ORIGIN, source: composeWin, data },
618
+ frames,
619
+ { origin: ORIGIN, parent, embedded: true },
620
+ );
621
+
622
+ assert.equal(parent.posts.length, 1, "the host (window.parent) must receive the forwarded nano-navigate");
623
+ assert.deepEqual(parent.posts[0], { data, origin: ORIGIN });
624
+ // A forward-up is NOT also broadcast back into the page's App-Views.
625
+ assert.equal(composeWin.posts.length, 0, "nano-navigate is forwarded up, not relayed across");
626
+ });
627
+
628
+ test("#518: relayAppViewMessage relays a sibling-directed message ACROSS to the other App-Views only", () => {
629
+ const parent = fakeWindow();
630
+ const libraryWin = fakeWindow();
631
+ const composeWin = fakeWindow();
632
+ const stagedWin = fakeWindow();
633
+ const library = fakeFrame(libraryWin);
634
+ const compose = fakeFrame(composeWin);
635
+ const staged = fakeFrame(stagedWin);
636
+ const frames = [library, compose, staged];
637
+ const data = { type: "nano-delivery-graph-compose-fill", xml: "<definitions/>" };
638
+
639
+ relayAppViewMessage(
640
+ { origin: ORIGIN, source: libraryWin, data },
641
+ frames,
642
+ { origin: ORIGIN, parent, embedded: true },
643
+ );
644
+
645
+ // Delivered to BOTH siblings...
646
+ assert.deepEqual(composeWin.posts, [{ data, origin: ORIGIN }], "sibling Compose must receive the relayed message");
647
+ assert.deepEqual(stagedWin.posts, [{ data, origin: ORIGIN }], "sibling Staged must receive the relayed message");
648
+ // ...but never echoed back to the originator, nor forwarded up to the host.
649
+ assert.equal(libraryWin.posts.length, 0, "the originating App-View must not receive its own message back");
650
+ assert.equal(parent.posts.length, 0, "a sibling-directed (non-navigate) message must not be forwarded to the host");
651
+ });
652
+
653
+ test("#518: relayAppViewMessage does NOT forward up when the page is standalone (not embedded)", () => {
654
+ const parent = fakeWindow();
655
+ const composeWin = fakeWindow();
656
+ const compose = fakeFrame(composeWin);
657
+ const data = { type: "nano-navigate", target: "processExplorer", params: {} };
658
+
659
+ relayAppViewMessage(
660
+ { origin: ORIGIN, source: composeWin, data },
661
+ [compose],
662
+ { origin: ORIGIN, parent, embedded: false },
663
+ );
664
+
665
+ assert.equal(parent.posts.length, 0, "standalone: there is no same-origin console host to forward to");
666
+ });
667
+
668
+ test("#518: relayAppViewMessage rejects a message from a NON-App-View source (trust boundary)", () => {
669
+ const parent = fakeWindow();
670
+ const composeWin = fakeWindow();
671
+ const compose = fakeFrame(composeWin);
672
+ const strangerWin = fakeWindow(); // some other framed window, not one of our App-Views
673
+ const data = { type: "nano-navigate", target: "definitionPreview", params: {} };
674
+
675
+ relayAppViewMessage(
676
+ { origin: ORIGIN, source: strangerWin, data },
677
+ [compose],
678
+ { origin: ORIGIN, parent, embedded: true },
679
+ );
680
+
681
+ assert.equal(parent.posts.length, 0, "a message from an unknown source must not be forwarded up");
682
+ assert.equal(composeWin.posts.length, 0, "a message from an unknown source must not be relayed across");
683
+ });
684
+
685
+ test("#518: relayAppViewMessage rejects a CROSS-ORIGIN message even from a known App-View window", () => {
686
+ const parent = fakeWindow();
687
+ const composeWin = fakeWindow();
688
+ const stagedWin = fakeWindow();
689
+ const compose = fakeFrame(composeWin);
690
+ const staged = fakeFrame(stagedWin);
691
+ const data = { type: "nano-delivery-graph-compose-fill", xml: "<x/>" };
692
+
693
+ relayAppViewMessage(
694
+ { origin: "https://evil.test", source: composeWin, data },
695
+ [compose, staged],
696
+ { origin: ORIGIN, parent, embedded: true },
697
+ );
698
+
699
+ assert.equal(stagedWin.posts.length, 0, "a cross-origin message must not be relayed across");
700
+ assert.equal(parent.posts.length, 0, "a cross-origin message must not be forwarded up");
701
+ });