Work when served from its own origin (cinny #43) #41

Merged
jared merged 1 commits from lotus-call-origin into lotus 2026-09-27 01:30:32 -04:00
Owner

Part of cinny #43: serve Element Call from its own origin (call.chat.lotusguild.org) so the call frame can't reach the chat app's storage (login token, crypto store) or service worker.

Why this fork change is needed

Two things tied EC to the host's origin, and both would break a cross-origin call:

  1. Widget message check (would stop calls starting). matrix-widget-api's strictOriginCheck compares ev.origin with this frame's own origin. On call.chat.lotusguild.org, every message from chat.lotusguild.org (join, mute, deafen, all io.lotus.*) would be dropped.
    • Fix: restrictToHost() accepts a message only if ev.source === window.parent and ev.origin is parentUrl's origin.
    • Same-origin deployments still work, because the host origin is our own origin there.
    • It's also stricter than today: the sender must be our parent window, not just any window with the right origin.
    • Turning the check off would have "worked" but let any window drive the call. I didn't do that.
  2. Soundboard (would silently fail). The host passes a blob: URL, and blob URLs only resolve on the origin that created them.
    • Verified locally: fetching the host's blob URL from the call frame works same-origin but throws TypeError cross-origin.
    • Fix: io.lotus.inject_audio now accepts the clip's bytes (audio, an ArrayBuffer of at most 8 MiB) and prefers them. url still works for older hosts.

Tests

  • Unit: lotusWidgetOrigin.test.ts (parent + origin required; listener swapped; foreign messages dropped) and lotusAudioInject.test.ts (bytes win, size cap, non-buffer rejected, unsafe URLs rejected). All 114 lotus tests pass; tsc and oxlint are clean.
  • End to end: local Synapse + LiveKit, app on 127.0.0.1:5173, this build served from 127.0.0.1:5174 (cross-origin, same-site like prod). Full results are in the cinny PR.

Rollout order (nothing reaches users until step 4)

This work is split across three PRs, plus a final one-line config change. Each step is safe on its own, and none of steps 1–3 changes how calls work for anyone.

Step What User-visible effect
1 Merge element-call lotus-call-origin → tag v0.25.0-lotus.21 → CI publishes the package None: nothing uses it yet.
2 In cinny call-origin-split, bump the pin to 0.25.0-lotus.21, then merge (deploys) None. Calls stay same-origin: elementCallUrl isn't set.
3 matrix call-origin-split: install the two header snippets on LXC 106 by hand (nginx -t), then merge (deploys nginx.conf with its own nginx -t + rollback). Create the NPM proxy host. Verify with curl. None. The new hostname serves only the call page, and no client points at it yet.
4 One-line change to cinny/config.json: "elementCallUrl": "https://call.chat.lotusguild.org/public/element-call/index.html" Web calls load from the call origin. Desktop is unchanged.

Rollback for step 4: remove that line. config.json deploys on merge with no rebuild, and every new call is back on the bundled same-origin page. The same-origin copy at chat.lotusguild.org/public/element-call/ stays in place for exactly this reason.

Order matters: step 4 must come after step 2 ships lotus.21. I tested step 4 against the released lotus.20 locally. The call looks joined (the End button shows), but lotus.20 drops every message from the host, so host controls do nothing. Bob unmuted from the call bar, and Alice never saw "bob is speaking": he'd be silently muted.

Review checklist

  • restrictToHost runs before any toWidget action is handled. It swaps the listener whether or not the transport has started.
  • No path accepts a message from a window other than window.parent.
  • The inject_audio byte cap (8 MiB) is fine for our clips.
  • Same-origin (the production state after step 2) still works. Verified with this branch's build in the dev server: join, both screenshare paths, PTT/deafen from inside the frame, layout/reactions/settings, the speaking indicator, soundboard, avatars and the muted-speech warning all pass. The foreign-frame spoof stays blocked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA

Part of cinny **#43**: serve Element Call from its own origin (`call.chat.lotusguild.org`) so the call frame can't reach the chat app's storage (login token, crypto store) or service worker. ## Why this fork change is needed Two things tied EC to the host's origin, and both would break a cross-origin call: 1. **Widget message check (would stop calls starting).** `matrix-widget-api`'s `strictOriginCheck` compares `ev.origin` with **this frame's own** origin. On `call.chat.lotusguild.org`, every message from `chat.lotusguild.org` (join, mute, deafen, all `io.lotus.*`) would be dropped. - Fix: `restrictToHost()` accepts a message only if `ev.source === window.parent` **and** `ev.origin` is `parentUrl`'s origin. - Same-origin deployments still work, because the host origin is our own origin there. - It's also **stricter than today**: the sender must be our parent window, not just any window with the right origin. - Turning the check off would have "worked" but let any window drive the call. I didn't do that. 2. **Soundboard (would silently fail).** The host passes a `blob:` URL, and blob URLs only resolve on the origin that created them. - Verified locally: fetching the host's blob URL from the call frame works same-origin but throws `TypeError` cross-origin. - Fix: `io.lotus.inject_audio` now accepts the clip's bytes (`audio`, an ArrayBuffer of at most 8 MiB) and prefers them. `url` still works for older hosts. ## Tests - Unit: `lotusWidgetOrigin.test.ts` (parent + origin required; listener swapped; foreign messages dropped) and `lotusAudioInject.test.ts` (bytes win, size cap, non-buffer rejected, unsafe URLs rejected). All 114 lotus tests pass; tsc and oxlint are clean. - End to end: local Synapse + LiveKit, app on `127.0.0.1:5173`, this build served from `127.0.0.1:5174` (cross-origin, same-site like prod). Full results are in the cinny PR. ## Rollout order (nothing reaches users until step 4) This work is split across three PRs, plus a final one-line config change. Each step is safe on its own, and none of steps 1–3 changes how calls work for anyone. | Step | What | User-visible effect | |---|---|---| | **1** | Merge **element-call** `lotus-call-origin` → tag `v0.25.0-lotus.21` → CI publishes the package | None: nothing uses it yet. | | **2** | In **cinny** `call-origin-split`, bump the pin to `0.25.0-lotus.21`, then merge (deploys) | None. Calls stay same-origin: `elementCallUrl` isn't set. | | **3** | **matrix** `call-origin-split`: install the two header snippets on LXC 106 by hand (`nginx -t`), then merge (deploys `nginx.conf` with its own `nginx -t` + rollback). Create the NPM proxy host. Verify with curl. | None. The new hostname serves only the call page, and no client points at it yet. | | **4** | One-line change to `cinny/config.json`: `"elementCallUrl": "https://call.chat.lotusguild.org/public/element-call/index.html"` | Web calls load from the call origin. Desktop is unchanged. | **Rollback for step 4:** remove that line. `config.json` deploys on merge with no rebuild, and every new call is back on the bundled same-origin page. The same-origin copy at `chat.lotusguild.org/public/element-call/` stays in place for exactly this reason. **Order matters:** step 4 must come **after** step 2 ships lotus.21. I tested step 4 against the released lotus.20 locally. The call *looks* joined (the End button shows), but lotus.20 drops every message from the host, so host controls do nothing. Bob unmuted from the call bar, and Alice never saw "bob is speaking": he'd be silently muted. ## Review checklist - [ ] `restrictToHost` runs before any toWidget action is handled. It swaps the listener whether or not the transport has started. - [ ] No path accepts a message from a window other than `window.parent`. - [ ] The `inject_audio` byte cap (8 MiB) is fine for our clips. - [ ] Same-origin (the production state after step 2) still works. Verified with this branch's build in the dev server: join, both screenshare paths, PTT/deafen from inside the frame, layout/reactions/settings, the speaking indicator, soundboard, avatars and the muted-speech warning all pass. The foreign-frame spoof stays blocked. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared added 1 commit 2026-09-26 23:42:21 -04:00
feat(lotus): work when served from its own origin (cinny #43)
CI / Publish to Gitea npm registry (pull_request) Skipped
CI / Build embedded bundle (pull_request) Successful in 3m43s
a93bd9d7d8
Two things tied EC to the host's origin:

- Widget message check: matrix-widget-api's strictOriginCheck compares
  ev.origin with THIS frame's origin, so on call.chat.lotusguild.org every
  message from chat.lotusguild.org would be dropped and calls would not
  start. restrictToHost() instead requires ev.source === window.parent and
  ev.origin === parentUrl's origin. Same-origin deployments keep working
  (the host origin is our own origin there), and it is stricter than
  before: the sender must also be our parent window.
- Soundboard: the host's blob: clip URL is origin-bound. io.lotus.inject_audio
  now accepts the clip's bytes (`audio`, ArrayBuffer, <= 8 MiB) and prefers
  them over `url`; hosts that only send `url` are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared merged commit 9d57d104b4 into lotus 2026-09-27 01:30:32 -04:00
Sign in to join this conversation.