Optional Element Call origin via config.json elementCallUrl (#43) #243

Merged
jared merged 2 commits from call-origin-split into lotus 2026-09-27 02:24:48 -04:00
Owner

Part of #43: serve Element Call from its own origin (call.chat.lotusguild.org).

Nothing changes when this merges. The new path only turns on when config.json sets elementCallUrl, which is the last step of the rollout below.

Why

Today the call page is served from chat.lotusguild.org itself. A same-origin frame with allow-scripts allow-same-origin has full access to the app. Measured locally, the call frame today can:

  • read parent.document
  • read all 27 of the app's localStorage entries (the login token among them)
  • use the app's service worker, which attaches your access token to media requests.

On its own origin, the same checks return SecurityError for the parent page and storage, and no service worker controls the frame. The sandbox flags stay the same: allow-same-origin is harmless once the origins differ, and EC needs it for its own storage.

What changes

  • callPageUrl.ts (+ tests): resolves elementCallUrl.
    • Only an absolute https URL is accepted (http only on localhost/127.0.0.1, for development).
    • Anything else (malformed, javascript:, data:, relative, plain http) falls back to the bundled page, so a typo can't break calls.
    • Ignored in the desktop app. It keeps its bundled copy: its CSP doesn't allow another frame origin, and a network copy could drift from the bundle.
  • CallEmbed: the widget URL comes from it. The widget origin used by the message guard (df395776) and by Capability Delegation follows automatically.
  • Soundboard: io.lotus.inject_audio also carries the clip's bytes, because the host's blob: URL can't be fetched from another origin.
    • It's backward compatible: I verified that the released lotus.20 ignores the extra field and still plays the clip (same-origin).

Before merging

  • Bump @lotusguild/element-call-embedded to 0.25.0-lotus.21 once the element-call PR is merged and tagged. This branch still pins lotus.20.

Test evidence

Local Synapse + LiveKit. The app runs on 127.0.0.1:5173; the call page is served from 127.0.0.1:5174, which is cross-origin but same-site, like chat. vs call.chat.. The call-page server sends the proposed production headers, and the app document gets the proposed Permissions-Policy.

Check Same-origin (today) Cross-origin (this)
Call joins ✓ ✓
Frame → parent.document / app localStorage readable (27 keys) SecurityError
Frame controlled by the app's service worker yes no
Mic in frame, current prod Permissions-Policy ✓ ✗ NotAllowedError (hence the matrix PR's header change)
Mic in frame, proposed Permissions-Policy ✓ ✓
Screenshare, Chromium (host button, delegated) ✓ start/stop ✓ start/stop
Screenshare, no delegation (in-frame button) ✓ ✓
Room policy hides the in-frame button ✓ ✓
PTT (Space) and deafen (M) with focus in the call ✓ ✓
Layout / reactions / settings from the host bar ✓ ✓
"bob is speaking", mic level bars, muted-speech warning ✓ ✓
Per-person volume slider + persistence ✓ ✓ (stored on the call origin now, see side effects)
Soundboard: extra audio track while the clip plays ✓ ✓ (✗ without the bytes change)
Avatars in call tiles ✓ ✓ (fetched by the host over the widget API)
ML noise suppression assets ✓ ✓ (loaded from the call origin, all 200)
Foreign frame posts a fake PTT keydown to the host blocked (df395776) blocked
Foreign frame posts toWidget set_layout to the call ignored ignored
Another origin tries to frame the call page n/a blocked (frame-ancestors)

Also: 1,268 unit tests pass, all 20 Playwright e2e tests pass, and vite build is clean.

Side effects to expect after step 4

  • Element Call's own saved settings (per-person volumes, its device choices) live in the call origin's storage, so they reset once.
  • Anyone already in a call keeps the old frame until they rejoin.

Known limits (not in scope)

  • The desktop app still loads its bundled call page same-origin. Changing that is a separate decision (CSP, offline, version drift).
  • The call page gets frame-ancestors but not yet a full CSP, same as today's framed headers. A tighter CSP for EC is possible later but needs its own testing.

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

  • resolveCallPageUrl can't be made to load anything but an https page (tests cover javascript:, data:, relative and http).
  • The desktop path is untouched (isTauri() → bundled page).
  • Every host → frame message still goes to the widget's exact origin (matrix-widget-api targetOrigin = widget.origin; delegation uses the iframe's real origin).
  • The soundboard change is harmless on lotus.20 (verified same-origin).
  • The rollout order is followed: step 4 only after lotus.21 ships.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA

Part of **#43**: serve Element Call from its own origin (`call.chat.lotusguild.org`). **Nothing changes when this merges.** The new path only turns on when `config.json` sets `elementCallUrl`, which is the last step of the rollout below. ## Why Today the call page is served from `chat.lotusguild.org` itself. A same-origin frame with `allow-scripts allow-same-origin` has full access to the app. Measured locally, the call frame today can: - read `parent.document` - read all **27** of the app's localStorage entries (the login token among them) - use the app's service worker, which attaches your access token to media requests. On its own origin, the same checks return `SecurityError` for the parent page and storage, and no service worker controls the frame. The sandbox flags stay the same: `allow-same-origin` is harmless once the origins differ, and EC needs it for its own storage. ## What changes - **`callPageUrl.ts`** (+ tests): resolves `elementCallUrl`. - Only an absolute **https** URL is accepted (http only on `localhost`/`127.0.0.1`, for development). - Anything else (malformed, `javascript:`, `data:`, relative, plain http) falls back to the bundled page, so a typo can't break calls. - **Ignored in the desktop app.** It keeps its bundled copy: its CSP doesn't allow another frame origin, and a network copy could drift from the bundle. - **`CallEmbed`**: the widget URL comes from it. The widget origin used by the message guard (`df395776`) and by Capability Delegation follows automatically. - **Soundboard**: `io.lotus.inject_audio` also carries the clip's bytes, because the host's `blob:` URL can't be fetched from another origin. - It's backward compatible: I verified that the released lotus.20 ignores the extra field and still plays the clip (same-origin). ## Before merging - [ ] Bump `@lotusguild/element-call-embedded` to `0.25.0-lotus.21` once the element-call PR is merged and tagged. This branch still pins lotus.20. ## Test evidence Local Synapse + LiveKit. The app runs on `127.0.0.1:5173`; the call page is served from `127.0.0.1:5174`, which is cross-origin but same-site, like `chat.` vs `call.chat.`. The call-page server sends the proposed production headers, and the app document gets the proposed `Permissions-Policy`. | Check | Same-origin (today) | Cross-origin (this) | |---|---|---| | Call joins | ✓ | ✓ | | Frame → `parent.document` / app `localStorage` | **readable** (27 keys) | **SecurityError** | | Frame controlled by the app's service worker | **yes** | no | | Mic in frame, **current** prod Permissions-Policy | ✓ | ✗ `NotAllowedError` (hence the matrix PR's header change) | | Mic in frame, **proposed** Permissions-Policy | ✓ | ✓ | | Screenshare, Chromium (host button, delegated) | ✓ start/stop | ✓ start/stop | | Screenshare, no delegation (in-frame button) | ✓ | ✓ | | Room policy hides the in-frame button | ✓ | ✓ | | PTT (Space) and deafen (M) with focus in the call | ✓ | ✓ | | Layout / reactions / settings from the host bar | ✓ | ✓ | | "bob is speaking", mic level bars, muted-speech warning | ✓ | ✓ | | Per-person volume slider + persistence | ✓ | ✓ (stored on the call origin now, see side effects) | | Soundboard: extra audio track while the clip plays | ✓ | ✓ (✗ without the bytes change) | | Avatars in call tiles | ✓ | ✓ (fetched by the host over the widget API) | | ML noise suppression assets | ✓ | ✓ (loaded from the call origin, all 200) | | Foreign frame posts a fake PTT keydown to the host | blocked (`df395776`) | blocked | | Foreign frame posts toWidget `set_layout` to the call | ignored | ignored | | Another origin tries to frame the call page | n/a | blocked (`frame-ancestors`) | Also: 1,268 unit tests pass, all 20 Playwright e2e tests pass, and `vite build` is clean. ## Side effects to expect after step 4 - Element Call's own saved settings (per-person volumes, its device choices) live in the call origin's storage, so they **reset once**. - Anyone already in a call keeps the old frame until they rejoin. ## Known limits (not in scope) - The desktop app still loads its bundled call page same-origin. Changing that is a separate decision (CSP, offline, version drift). - The call page gets `frame-ancestors` but not yet a full CSP, same as today's framed headers. A tighter CSP for EC is possible later but needs its own testing. ## 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 - [ ] `resolveCallPageUrl` can't be made to load anything but an https page (tests cover `javascript:`, `data:`, relative and http). - [ ] The desktop path is untouched (`isTauri()` → bundled page). - [ ] Every host → frame message still goes to the widget's exact origin (matrix-widget-api `targetOrigin = widget.origin`; delegation uses the iframe's real origin). - [ ] The soundboard change is harmless on lotus.20 (verified same-origin). - [ ] The rollout order is followed: step 4 only after lotus.21 ships. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared added 1 commit 2026-09-26 23:42:22 -04:00
feat(call): optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m27s
4dcc5176e2
Groundwork for serving the call page from its own origin
(call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`:
without it the bundled same-origin page is used exactly as today.

- callPageUrl: resolves `elementCallUrl` — absolute https only (http only on
  localhost for development); anything else, and the desktop app, fall back
  to the bundled page so a bad value can't break calls. Set once from the
  loaded client config.
- CallEmbed builds the widget URL from it; the widget origin (used by the
  message guard and Capability Delegation) follows automatically.
- Soundboard: a host blob: URL can't be fetched from another origin, so
  io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks
  that predate it ignore the field and use `url`, so this is safe on the
  released fork.

Needs element-call's lotus-call-origin branch (host-origin message check +
inject_audio bytes) released and pinned before `elementCallUrl` is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared added 1 commit 2026-09-27 02:05:22 -04:00
chore(call): pin element-call-embedded 0.25.0-lotus.21 (#43)
CI / Build & Quality Checks (pull_request) Successful in 5m10s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m21s
da78bff316
lotus.21 checks widget messages against the host's origin (works same- and
cross-origin) and accepts soundboard clip bytes. Same-origin behaviour is
unchanged; verified against the published package: join, both screenshare
paths, PTT/deafen in the frame, layout/reactions/settings, speaking and mic
level, soundboard, avatars, muted-speech warning, per-person volume, and the
foreign-frame spoof stays blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared merged commit 773e41311e into lotus 2026-09-27 02:24:48 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#243