feat(lotus): work when served from its own origin (cinny #43)
CI / Build embedded bundle (pull_request) Successful in 3m43s
CI / Publish to Gitea npm registry (pull_request) Skipped

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
This commit is contained in:
Lotus CI
2026-09-26 23:24:32 -04:00
co-authored by Claude Opus 5.5
parent 43a5e93c37
commit a93bd9d7d8
5 changed files with 213 additions and 36 deletions
+36 -1
View File
@@ -10,7 +10,11 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { of } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { startLotusAudioInject } from "./lotusAudioInject";
import {
MAX_INJECT_BYTES,
parseInjectSource,
startLotusAudioInject,
} from "./lotusAudioInject";
import { LotusWidgetActions } from "./lotusActions";
const lazyActions = new EventEmitter();
@@ -223,3 +227,34 @@ test("#14: the shared context stays open while another instance is still active"
stopB();
expect(ctx.close).toHaveBeenCalledTimes(1);
});
// [cinny #43] Cross-origin hosts send the clip bytes instead of a blob: URL.
test("parseInjectSource prefers the clip bytes over the url", () => {
const audio = new ArrayBuffer(16);
expect(parseInjectSource({ audio, url: "https://x.example/a.ogg" })).toBe(
audio,
);
});
test("parseInjectSource falls back to a safe url", () => {
expect(parseInjectSource({ url: "https://x.example/a.ogg" })).toBe(
"https://x.example/a.ogg",
);
expect(
parseInjectSource({
audio: new ArrayBuffer(0),
url: "https://x.example/a.ogg",
}),
).toBe("https://x.example/a.ogg");
expect(parseInjectSource({ url: "javascript" + ":alert(1)" })).toBeNull();
expect(parseInjectSource({})).toBeNull();
expect(parseInjectSource(undefined)).toBeNull();
});
test("parseInjectSource rejects oversized or non-buffer audio", () => {
expect(
parseInjectSource({ audio: new ArrayBuffer(MAX_INJECT_BYTES + 1) }),
).toBeNull();
expect(parseInjectSource({ audio: "not bytes" })).toBeNull();
expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull();
});