From 888ed1a66c31a5fdf30ad80b93ddafbfa4506d23 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 18 Oct 2026 01:22:49 -0400 Subject: [PATCH] fix(lotus): soundboard says "not permitted" in audio-only rooms instead of failing silently (#47) Clips publish as an Unknown-source track. In audio-only rooms the guard signs a microphone-only token, LiveKit refuses the publish, and since the fork had already replied {} the host never heard. Check the grants up front and reply { played: false, reason: "not_permitted" }. Refs #47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/lotus/lotusAudioInject.test.ts | 15 +++++++++++++++ src/lotus/lotusAudioInject.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/lotus/lotusAudioInject.test.ts b/src/lotus/lotusAudioInject.test.ts index f919661f..9570df11 100644 --- a/src/lotus/lotusAudioInject.test.ts +++ b/src/lotus/lotusAudioInject.test.ts @@ -11,6 +11,7 @@ import { of } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { + canPublishSoundboard, MAX_INJECT_BYTES, parseInjectSource, startLotusAudioInject, @@ -258,3 +259,17 @@ test("parseInjectSource rejects oversized or non-buffer audio", () => { expect(parseInjectSource({ audio: "not bytes" })).toBeNull(); expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull(); }); + +test("canPublishSoundboard: refused by a microphone-only grant (EC #47)", () => { + expect(canPublishSoundboard(undefined)).toBe(true); + expect( + canPublishSoundboard({ canPublish: true, canPublishSources: [] }), + ).toBe(true); + expect( + canPublishSoundboard({ canPublish: true, canPublishSources: [2] }), + ).toBe(false); + expect( + canPublishSoundboard({ canPublish: true, canPublishSources: [2, 0] }), + ).toBe(true); + expect(canPublishSoundboard({ canPublish: false })).toBe(false); +}); diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index 59b2fb3a..19a16485 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -60,6 +60,22 @@ function acquireSharedAudio(): { * No effect unless the host sends the action. Returns a teardown function that * also aborts any clip still playing. */ +/** + * Pure: whether these LiveKit permissions allow publishing the soundboard's + * Unknown-source track (EC #47). No permissions or an empty source list + * means unrestricted; otherwise UNKNOWN (TrackSource 0) must be allowed. + */ +export function canPublishSoundboard( + perm: + | { canPublish?: boolean; canPublishSources?: readonly number[] } + | undefined, +): boolean { + if (!perm) return true; + if (perm.canPublish === false) return false; + const sources = perm.canPublishSources ?? []; + return sources.length === 0 || sources.includes(0); +} + export function startLotusAudioInject(vm: CallViewModel): () => void { const w = widget; if (!w) return () => undefined; @@ -116,6 +132,19 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { return; } + // [EC #47] Clips publish as an Unknown-source track. Audio-only rooms get + // a token limited to the microphone source, where LiveKit refuses that + // publish *after* we replied; say so up front instead of failing silently. + if ( + rooms.some((r) => !canPublishSoundboard(r.localParticipant.permissions)) + ) { + w.api.transport.reply(ev.detail, { + played: false, + reason: "not_permitted", + }); + return; + } + w.api.transport.reply(ev.detail, {}); void playInjectedClip(clip, volume, rooms, activeClips).catch((e) => logger.warn("[lotus] inject_audio failed", e), -- 2.47.3