LOOM_PLAN

Loom Plan: Render Galaxy Notebooks Server-Side in Orbit (iframe embed)

Status: In progress — Galaxy Phases 1.1–1.5 done, green, and committed (80c9cc1f65 on notebook_iframe, 23 files). Loom Phase 2 brain-side + Phase 3 (transport, 3.3/3.4 partition, 3.1/3.2 toggle/embed view, 3.6 refresh) done & green and now committed on notebook_iframe in the Loom worktree (two commits: 4381a4e brain/shared, ce34e99 Orbit shell — not pushed). Covers: embed-URL helper + NotebookEmbed/EmbedToken contracts + ui-bridge emission, mintEmbedToken + embed-token-manager refresh loop + embed-token-bridge, brain↔main token channel (swallowed widget key — closes the 2.3 wiring interlock), locked-down persist:galaxy-embed partition, and the Markdown↔Galaxy <webview> toggle (vehicle: webview, not iframe — §3.5 postMessage superseded by native events, Divergence 4). Full suite 990 green. Live-verified end-to-end 2026-06-22 — the full path works and all 3 E2E bugs (incl. the critical one) are now FIXED & live-verified (see §0.0; Loom-side, uncommitted at time of writing). (Still open: external-link open / 401→reload — see §0.0 tail.) Date: 2026-06-20 (Phase 2 brain-side + Phase 3 transport/partition/UI/refresh, committed: 2026-06-22) Goal: Let Orbit’s notebook display pane show the notebook as rendered by the Galaxy server (in an <iframe>), so Galaxy visualizations, dataset displays, and charts render fully and pixel-match Galaxy — instead of (or alongside) Orbit’s local markdown render. Sub-design: LOOM_1B_EMBED_AUTH_DESIGN.md (the scoped embed-token auth path).


0.0 ⚠️ Live E2E results (2026-06-22) — READ FIRST

✅ UPDATE 2026-06-22 (later same day): all 3 bugs below are FIXED and live-verified. All fixes landed Loom-side (the Bug-1 Orbit-side option (a) — no Galaxy changes). 990 root tests green, root + app typecheck clean, lint 0 errors. Re-ran the full live E2E against the same Galaxy+Orbit: Bug 1 — in-<webview> fetch('/api/pages/{id}') now 200 (was 403) and document.cookie is empty, page renders its real content (no access-denied); Bug 2 — token minted once, steady over 12 s (no re-mint loop); Bug 3 — after an Orbit restart with no /sync push, the token minted from the inherited bound notebook, the toggle was available, and the <webview> was live with the correct embed src. Per-bug ”✅ FIXED” notes inline below. 11-file diff in the Loom worktree (7 source + 4 test). The diagnostic write-up is kept as the record of why.

First end-to-end run against a real running Galaxy + Orbit (not unit tests). Outcome: the entire pipeline works — /sync push --history <id> creates the page, binds the notebook, emits NotebookEmbed, mints the embed token (held by main, never sent to renderer), the Markdown↔Galaxy toggle enables, the <webview> loads the embed URL, and Galaxy’s Vue app boots inside it. Header injection is confirmed working (instrumented onBeforeSendHeaders: injected=true on /api/pages). The page just fails the final server authorization. Three bugs found (all now fixed):

The webview’s /published/page document load makes Galaxy set an anonymous galaxysession cookie in the persist:galaxy-embed partition. Every later /api/pages/{id} request then carries both that cookie and the correctly injected x-galaxy-embed-token — and Galaxy resolves the cookie session first, so the embed token is ignored → 403 "Page is not accessible to the current user" (err_code 403002). Isolated to one variable (same token, same server):

requestresult
embed header only (cookie-less, e.g. curl)200
embed header + anonymous galaxysession cookie (the webview)403

Note get_api_user (Galaxy api/__init__.py) does check request.state.embed_user before the api-key path — but a cookie session is resolved ahead of that, so the header-vs-api-key precedence is not the relevant one; cookie-vs-embed-token is.

Fix options (probably want both; (a) alone unblocks today):

✅ FIXED (option a). Added a pure stripHeader(headers, name) helper to embed-partition.ts (generic over string request / string[] response values). In embed-partition-session.ts, onBeforeSendHeaders now strips Cookie on Galaxy-origin requests (alongside injecting the token), and onHeadersReceived strips Set-Cookie — so the partition is stateless and Galaxy authenticates by the token alone. Live proof: in-<webview> fetch('/api/pages/{id}')200, document.cookie → empty. Option (b) was NOT done (no Galaxy change needed). 4 unit tests added.

🟠 BUG 2 — EmbedTokenManager re-mints ~1×/second (runaway refresh loop)

Galaxy returns expires_at as naive UTC with no Z/offset (2026-06-22T07:19:26.134849). JS new Date() parses a tz-less ISO string as local time (here UTC+2) → computed expiry is ~2 h in the past → refresh delay floors to MIN_REFRESH_DELAY_MS (1 s) → mints forever, one new server-side page_embed_token row per second (no cleanup → table bloat; also churns the UI). Fix: parse Galaxy’s timestamp as UTC (append Z / treat as UTC) in the brain’s refreshDelayMs/shouldRefreshToken (galaxy-embed.ts). Galaxy could also emit an offset-aware ISO timestamp. Add a unit test with a tz-less input.

✅ FIXED (brain-side). New parseExpiryMs in galaxy-embed.ts appends Z to a tz-less ISO datetime (detected by the absence of a trailing Z/offset) so it parses as UTC; both shouldRefreshToken and refreshDelayMs use it. Galaxy still emits the naive stamp (option to fix server-side left open, not needed). Live proof: 1 mint, steady over 12 s. 2 unit tests added (incl. the exact microsecond, tz-less shape Galaxy emits).

🟠 BUG 3 — embed view unavailable after session resume (--continue)

NotebookEmbed/token emission is gated on onNotebookChange + a live latestCtx, and the notebook watcher uses ignoreInitial: true. So when Orbit relaunches and the brain resumes the pi session (--continue), an already-bound notebook never re-emits → Galaxy toggle shows “unavailable” until a fresh bind in a fresh session. Fix: emit NotebookEmbed (+ mint) on startup/restore for an already-bound notebook, not only on change.

✅ FIXED (brain-side). Both bridges (ui-bridge.ts, embed-token-bridge.ts) now also capture ctx on session_start and replay the current notebook via a new readCurrentNotebook() in state.ts — robust to handler ordering, deduped by the existing last/setPage-no-op so a later change doesn’t double-fire. Also added isGalaxyEffectivelyConnected() to state.ts (env creds GALAXY_URL+ GALAXY_API_KEY — what the API client actually resolves — OR the in-session flag) as the mint gate, since the raw isGalaxyConnected flag is false on an env-creds resume until a Galaxy tool runs; context.ts’s two duplicated connection predicates were refactored onto the same helper. Live proof: Orbit restart with no /sync push → token minted, toggle available, <webview> live. 3 unit tests added.

How to reproduce the live env (for the next agent)


0. Worktrees (read first)

There are two worktrees both named notebook_iframe, both in scope. Galaxy mods land in the Galaxy one; Loom/Orbit mods land in the Loom one (the default cwd for this work):

RepoPathBranch
Loom (brain + Orbit shell)/Users/jxc755/projects/worktrees/loom/branch/notebook_iframenotebook_iframe
Galaxy (server + Vue client)/Users/jxc755/projects/worktrees/galaxy/branch/notebook_iframenotebook_iframe (off dev)

This plan modifies both repos. Galaxy dev already contains the Pages / Galaxy-Notebooks feature (page.history_id, history_tools.py, /api/pages, PageView.vue, HistoryPageView.vue), so no rebasing onto history_pages is needed.


0.5 Implementation status & divergences (live)

Done & green (Galaxy worktree):

Model pivot (per-resource scope DROPPED). The token now recovers the full owning user, not a restricted scope. embed_allowed=True admits the route; Galaxy’s own ACLs (get_accessible, history ownership) decide which resources. Read-only by virtue of marking only GET/HEAD routes. Load-bearing invariant: never mark a listing/enumeration route (esp. the plugin ?history_id= branch). Rationale: ids are not a security boundary, so an id-membership check was redundant

Done & green (Phase 1.1 + 1.4):

Done & green (Phase 1.2 + 1.5):

Done & green (Phase 2 foundation — Loom worktree, committed 4381a4e): The shell-neutral, pure pieces of Phase 2 that unit-test without Orbit. 32 tests pass (tests/galaxy-embed.test.ts 14 + 2 new in tests/galaxy-pages-api.test.ts); root tsc --noEmit + prettier + eslint clean.

Done & green (Phase 2 emission wiring — Loom worktree, committed 4381a4e):

Done & green (Phase 2.3 token lifecycle — Loom worktree, committed 4381a4e):

Done & green (Phase 3 token transport — brain↔main; brain in 4381a4e, main interception in ce34e99): This closes the 2.3 wiring interlock. Transport decision: the brain delivers the secret as a swallowed widget key — no Pi-protocol change; the brain stays shell-neutral; the Loom CLI shell just ignores the key.

Done & green (Phase 3.3/3.4 partition + header injection — Loom worktree, committed ce34e99):

Done & green (Phase 3.1/3.2 toggle + embed view — Loom worktree, committed ce34e99): Embed vehicle decision: <webview> (not <iframe>) — a plain iframe can’t target a custom Electron partition, and the whole lockdown lives on persist:galaxy-embed. So webviewTag: true is enabled in main.ts webPreferences, and the Galaxy view is a <webview partition="persist:galaxy- embed">.

Done & green (Phase 3.6 refresh-on-sync — Loom worktree, committed ce34e99):

Phase 3.5 postMessage — SUPERSEDED by the webview vehicle (see Divergence 4):

Not yet done (Phase 3 remainder):

Divergence 1 — enforcement strategy: central gate → per-route decorator

The plan originally recommended a central default-deny gate (§3 Option A framing). During design we chose, and implemented, a per-route boolean embed_allowed=True decorator instead — it reuses Galaxy’s existing FrameworkRouter._handle_galaxy_kwd machinery (same as require_admin/public), is default-deny by construction (the embed token only authenticates on opted-in routes; elsewhere it is ignored → 401). No security_check surgery. (The decorator originally carried a per-resource scope tuple; that was later dropped in favor of full-user recovery — see the model-pivot note above.) Rationale, spike results (FastAPI dep ordering; request.state works / ContextVar does not), and the full comparison live in LOOM_1B_EMBED_AUTH_DESIGN.md.

Divergence 2 — legacy dataset-display route — RESOLVED → (a) repoint client

The page’s dataset element fetched dataset content from the legacy /datasets/{id}/display/ controller route (controllers/dataset.py), not a FastAPI /api/... route, so embed_allowed could not decorate it. Subagent research resolved it to (a) repoint the client — no backend porting:

Rejected (b) (legacy controller can’t take the decorator; a second auth surface) and (c) (enumeration done). Visualization-frame routes are a separate, not-yet-analyzed slice — Vitessce already uses /api/datasets/{id}/display, but the general viz path is its own sub-decision. Detail in LOOM_1B_EMBED_AUTH_DESIGN.md.

Divergence 4 — embed vehicle: <iframe><webview>, and postMessage → native events

The plan said “iframe,” but a plain <iframe> can’t target a custom Electron partition — and the whole token-injection / frame-strip / nav-block lockdown lives on persist:galaxy-embed. So the Galaxy view is an Electron <webview partition="persist:galaxy-embed"> (webviewTag: true). Consequence for §3.5: Galaxy’s embedBridge.ts guards if (win.parent === win) return, which is true inside a webview (it’s its own top frame), so the galaxy-embed:* posts never fire there. The webview therefore uses native events instead — height is unnecessary (it fills a fixed pane), title/readypage-title-updated / dom-ready, navigation containment ← the partition’s onBeforeRequest. The postMessage bridge stays valid for a future iframe-based web shell; it’s just not Orbit’s path. Open items: off-origin-link→external-open (needs a scoped main handler) and 401→reload; both pending live verification.

Other discovery


1. Current state (verified against the code)

1.1 Loom already syncs notebook.md → a Galaxy Page

The sync layer exists today in extensions/loom/:

The bound page is a history-attached notebook (history_id set), i.e. private, not a published page. No view/embed URL is persisted today.

1.2 Orbit renders the notebook locally as markdown

1.3 Galaxy’s embeddable render surface


2. The core problem

To show the real Galaxy render of a private, history-attached notebook in Orbit’s pane, three things must be true simultaneously:

  1. Chrome-free — render only the notebook body (no masthead/sidebars). → satisfied by /published/page?id=X&embed=true (PageView, isChromeFree).
  2. Frame-embeddable — no X-Frame-Options: SAMEORIGIN block. → satisfied for /published/*?embed=true (the only bypass).
  3. Authenticated without an interactive Galaxy login — the embedded SPA’s /api/pages/{id} (and nested dataset/viz) calls must authenticate as the user. Orbit has an API key, not a browser session. → this is the hard part and the reason to modify Galaxy.

There is no interactive Galaxy login inside Orbit, and a cross-origin iframe shares no cookies with anything. So we must get credentials to the embedded SPA.


3. Design options for auth (the decision that shapes everything)

Option A — Publish the page (make it public), embed /published/page?embed=true

Option B — Electron injects the full API key per-partition — REJECTED (unsafe)

Inject X-API-Key: <full key> on every Galaxy-origin request in the iframe partition. Security research (Q5) rules this out: Galaxy’s dataset-display and visualization iframes render same-origin with no sandbox attribute (HistoryDatasetDisplay.vue:65, VisualizationFrame.vue:99). Any JS running there — visualizations always run JS; HTML datasets do when sanitize_all_html is off or the producing tool is allowlisted — can call GET /api/users/me/api_key (the injected header attaches automatically) and exfiltrate the user’s full, permanent API key (api/users.py:244). The injected key also grants ambient authority for destructive calls. The key being header-only (JS-invisible) does not help, because exfiltration goes through Galaxy’s own api-key endpoint, not header reads. Do not inject the full key.

Option C — Galaxy short-lived, page-scoped embed token (the credential)

Add POST /api/pages/{id}/embed_token → a short-TTL (mins), read-only token bound to (user, page_id). Auth path resolves it to a capability scope that allows reading exactly that page and the datasets/visualizations it references — and explicitly rejects user/account/api-key endpoints. Reuse the PasswordResetToken DB-backed pattern (Q7) for the EmbedToken model, but note the model is the easy part — Galaxy has no scoped-credential concept, so the real work is the scoped auth path threading through datasets/viz/legacy-controller authz (§4 1.3: ~75–175 LOC, not 40). This fills a genuine gap and is portable to any shell (web, browser, hosted), not just Electron.

Recommendation — Option C credential, injected via the Electron partition (C+B-mechanism)

Use Option C’s scoped token as the credential, delivered via Option B’s injection mechanism:

This is the answer to “modify Galaxy when it makes sense”: the scoped token + CSP are the Galaxy changes that make embedding correct, least-privilege, and shell-portable; the Electron partition is just the delivery mechanism.

Open sub-decision (nested-frame auth): three ways to get the credential to the nested same-origin dataset/viz frames — (i) inject the token at the Electron network layer for all Galaxy-origin requests (recommended; simplest, covers nested frames automatically); (ii) exchange the embed token for a short-lived scoped session cookie on first load (auto-covers nested frames in any browser incl. the web shell, but requires Galaxy to support a scoped/ read-only session — a new concept); (iii) token in every embed URL (requires rewriting all dataset/viz display URLs in the export pipeline — messy). See §9 Q-A.


4. Implementation plan

Phase 1 — Galaxy: a first-class, embeddable notebook surface

(worktree: …/galaxy/branch/notebook_iframe)

1.1 Configurable embed origins + frame-ancestors CSP.

1.2 Extend _is_embed_request (or add a notebook embed route).

1.3 Embed token + scoped-read auth path (Option C). ← the hard part. This is the security-critical, “modify Galaxy when it makes sense” centerpiece and the v1 critical path. Scope estimate: not “~40 LOC.” Galaxy has no scoped/ capability credential concept today — get_api_user (api/__init__.py:163-188) always resolves to a full User, and nested-resource authz is enforced in 3+ heterogeneous places: datasets via RBAC can_access_dataset (model/security.py:492, managers/datasets.py:119), the legacy dataset controller’s own path (controllers/dataset.py), and visualizations via security_check (managers/base.py:116). A genuine page-scoped capability must thread through all of them. Realistic: ~75–175 LOC across several files + migration + tests.

1.4 embed_url on the page schema (convenience).

1.5 postMessage embed protocol (client — the strongest UX win).

1.6 Tests (red→green).

Phase 2 — Loom brain: expose the embed URL + keep server in sync

(worktree: …/loom/branch/notebook_iframe)

2.1 Derive/persist the embed URL. — DONE (helper, committed 4381a4e).

2.2 New shell-contract payload (keep brain shell-neutral). — DONE.

2.3 Mint the embed token (when Option C lands). — PARTIAL.

2.4 Sync freshness.

2.5 Tests. — DONE for foundation + emission wiring + token lifecycle (47 green).

Phase 3 — Orbit shell: the iframe view + toggle

(worktree: …/loom/branch/notebook_iframe/app)

Token transport + partition/injection (3.3/3.4) — DONE & green. Brain→main embed-token channel via a swallowed EmbedToken widget key (decision: no Pi- protocol change): embed-token-bridge.ts (brain) → embed-token-store.ts + agent.ts handleLine (main, intercept + hold, never forward). Locked-down persist:galaxy-embed partition: embed-partition.ts (pure trust boundary) + embed-partition-session.ts (webRequest hooks) + main.ts wiring + CSP. See the two §0.5 “Phase 3” done blocks. Remaining: the renderer toggle/iframe (3.1/3.2) + postMessage (3.5/3.6) — first end-to-end exercise of the partition.

3.1 View-mode toggle in the Notebook tab. — DONE (see §0.5).

3.2 Render the iframe. — DONE as <webview> (see §0.5; iframe can’t take a partition).

3.3 Locked-down session partition + scoped-token injection. — DONE (see §0.5).

⚠️ Inject the scoped embed token, NOT the API key. Injecting the full key is the rejected Option B (§3, §6) — an exfiltrated key is total account compromise; an exfiltrated embed token is one page, read-only, minutes.

3.4 CSP + webPreferences. — DONE (CSP frame-src blob: https: + webviewTag: true; see §0.5).

3.5 postMessage handling. — SUPERSEDED by the webview vehicle (see Divergence 4 + §0.5).

3.6 Refresh on sync + token lifecycle. — DONE (reload-on-rev; 401→reload pending; see §0.5).

3.7 Tests.


5. End-state data flow

notebook.md  --/sync push-->  Galaxy Page (private, history-attached, new revision)
     |                               |
 loom-galaxy-page block         /api/pages/{id}  (embed-expanded `content`)
     |                               ^
 brain getEmbedUrl()                 | scoped embed TOKEN injected by Orbit
     |  + mints embed token          |   partition (X-Galaxy-Embed-Token header;
     |  NotebookEmbed widget         |   covers nested dataset/viz frames too)
     v  (shared contract)            |   — NEVER the full API key (§3 Option B)
Orbit Notebook pane [Galaxy mode]    |
     |  <iframe src=                 |
     |   /published/page?id=<encoded>&embed=true[&rev]>
     +------------------ renders PageView (chrome-free) --------------------+
                         live dataset displays + visualizations + charts
                         postMessage: ready / height / title / navigate

Markdown mode keeps the existing local marked+DOMPurify render for offline / fast / unsynced cases.


6. Security considerations


7. Testing strategy (red→green throughout)

  1. Galaxy API/middleware (pytest): embed-token mint/validate/expiry; scope tests that matter — token reads its page (200), a different page (403), the page’s referenced dataset/viz (200), an unreferenced dataset (403), and GET /api/users/me/api_key (403) — the last proves the Q5 hole is closed; frame-ancestors emitted only for embed paths; embed_url in schema.
  2. Galaxy client (Vitest + Playwright): PageView chrome-free under embed=true (and no back-button/toolbar); postMessage ready/height/navigate; link interception.
  3. Loom brain (Vitest): getEmbedUrl (encoded id, no double-encode); NotebookEmbed contract; token mint/refresh-before-expiry; /sync push emits fresh revision.
  4. Orbit (Vitest + Electron integration): toggle persistence; fallback to Markdown when unbound/disconnected; token injection scoped to Galaxy host (never the key, never other hosts); X-Frame strip in partition; iframe refresh on new revision; 401 → reload.
  5. End-to-end (automate, don’t just eyeball): bind a private notebook with a Galaxy viz directive → /sync push → Galaxy mode → Playwright assertion that the viz iframe/canvas actually paints (not merely that /api/pages 200s) and the masthead is absent. Fidelity is the feature’s whole purpose, so assert it.

Per repo conventions: run root tests + app typecheck after brain/shell changes; for Galaxy, prefer one suite at a time (the /galaxy-backend-tests and /galaxy-playwright skills).


8. Suggested sequencing

  1. Galaxy embed token (Phase 1.3) + auth-path scoping: the credential. On the critical path — nothing safe ships without it. Land model + migration + mint endpoint + scoped read auth + tests first.
  2. Reuse /published/page?embed=true (Phase 1.2) + Orbit shell (3.1–3.4): toggle + locked-down partition injecting the token + CSP frame-src. First working full-fidelity view.
  3. postMessage UX (Phase 1.5) + Orbit 3.5–3.6: height auto-resize, link interception, refresh-on-sync — makes the pane pleasant.
  4. Brain contract (Phase 2): move embed-URL/token ownership into the brain; NotebookEmbed widget; token refresh.
  5. frame-ancestors CSP + embed_url schema (Phase 1.1 + 1.4) and, if in scope, scoped-session-cookie delivery (Q-A-ii) to unblock the web shell and plain-browser embedding.

Steps 1 + 5 are the “modify Galaxy when it makes sense” core.


9. Questions

Resolved by research

New decision surfaced

Decisions (settled 2026-06-20)

  1. Auth: token-first v1. Ship the scoped embed token + Electron injection together. The Galaxy token work is accepted on the critical path; no full-key shortcut ships ahead of it.
  2. Refresh: build both, default manual. Ship manual “push-and-view” (run /sync push → Orbit reloads the iframe). Add opt-in push-on-save as a preference (off by default, since /sync push is local-wins/clobbers).
  3. Web shell: design for it, build Orbit-only now. Choose the token design so the token→scoped-session-cookie exchange (Q-A-ii) can be added later without reworking the model or auth path. Don’t build the cookie path in v1.
  4. Offline UX: fall back to local Markdown. Markdown (local marked+DOMPurify) stays the default view and the always-available fallback; the Galaxy iframe mode is offered only when the notebook is bound and Galaxy is connected.

Still open (minor / defer)