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:
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.parentandev.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.
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-calllotus-call-origin → tag v0.25.0-lotus.21 → CI publishes the package
None: nothing uses it yet.
2
In cinnycall-origin-split, bump the pin to 0.25.0-lotus.21, then merge (deploys)
matrixcall-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.
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
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 lotus2026-09-27 01:30:32 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
matrix-widget-api'sstrictOriginCheckcomparesev.originwith this frame's own origin. Oncall.chat.lotusguild.org, every message fromchat.lotusguild.org(join, mute, deafen, allio.lotus.*) would be dropped.restrictToHost()accepts a message only ifev.source === window.parentandev.originisparentUrl's origin.blob:URL, and blob URLs only resolve on the origin that created them.TypeErrorcross-origin.io.lotus.inject_audionow accepts the clip's bytes (audio, an ArrayBuffer of at most 8 MiB) and prefers them.urlstill works for older hosts.Tests
lotusWidgetOrigin.test.ts(parent + origin required; listener swapped; foreign messages dropped) andlotusAudioInject.test.ts(bytes win, size cap, non-buffer rejected, unsafe URLs rejected). All 114 lotus tests pass; tsc and oxlint are clean.127.0.0.1:5173, this build served from127.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.
lotus-call-origin→ tagv0.25.0-lotus.21→ CI publishes the packagecall-origin-split, bump the pin to0.25.0-lotus.21, then merge (deploys)elementCallUrlisn't set.call-origin-split: install the two header snippets on LXC 106 by hand (nginx -t), then merge (deploysnginx.confwith its ownnginx -t+ rollback). Create the NPM proxy host. Verify with curl.cinny/config.json:"elementCallUrl": "https://call.chat.lotusguild.org/public/element-call/index.html"Rollback for step 4: remove that line.
config.jsondeploys on merge with no rebuild, and every new call is back on the bundled same-origin page. The same-origin copy atchat.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
restrictToHostruns before any toWidget action is handled. It swaps the listener whether or not the transport has started.window.parent.inject_audiobyte cap (8 MiB) is fine for our clips.🤖 Generated with Claude Code
https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA