From 021b1881e511bb4ce27c5a128574a6269801b7a1 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 19 Sep 2026 14:04:45 -0400 Subject: [PATCH] fix(lotus): LocalTransport must not re-wrap SFUTokenRefusedError mapAuthErrorToUserFriendlyError turned every non-whitelisted error back into FailToGetOpenIdToken, so lotus.5's refusal reason still surfaced as the generic page (caught end-to-end with a routed 403 on /sfu/get). Pass it through like the other user-facing auth errors. Unit-tested. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../localMember/LocalTransport.test.ts | 40 +++++++++++++++++++ .../localMember/LocalTransport.ts | 5 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts index 09f6ecec..310fb145 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.test.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -37,6 +37,7 @@ import { Epoch, ObservableScope } from "../../ObservableScope"; import { MatrixRTCTransportMissingError, FailToGetOpenIdToken, + SFUTokenRefusedError, } from "../../../utils/errors"; import * as openIDSFU from "../../../livekit/openIDSFU"; import { customLivekitUrl } from "../../../settings/settings"; @@ -125,6 +126,45 @@ describe("LocalTransport", () => { expect(() => active$.value).toThrow(expectedError); }); + it("[lotus] passes SFUTokenRefusedError through untouched", async () => { + const scope = new ObservableScope(); + mockConfig({ + livekit: { livekit_service_url: "https://lk.example.org" }, + }); + const refused = new SFUTokenRefusedError("This voice channel is full."); + vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation( + async () => { + throw refused; + }, + ); + const errors: Error[] = []; + const { active$ } = createLocalTransport$({ + scope, + roomId: "!example_room_id", + memberships$: constant(new Epoch([])), + client: { + baseUrl: "https://example.org", + getDomain: () => "example.org", + // eslint-disable-next-line @typescript-eslint/naming-convention + _unstable_getRTCTransports: async () => Promise.resolve([]), + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + }, + ownMembershipIdentity: ownMemberMock, + forceJwtEndpoint: JwtEndpointVersion.Legacy, + delayId$: constant("delay_id_mock"), + }); + active$.subscribe( + () => undefined, + (e) => errors.push(e), + ); + await flushPromises(); + expect(errors).toStrictEqual([refused]); + expect((errors[0] as SFUTokenRefusedError).localisedMessage).toBe( + "This voice channel is full.", + ); + }); + it("emits preferred transport after OpenID resolves", async () => { // Use config so transport discovery succeeds, but delay OpenID JWT fetch mockConfig({ diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index f98a266f..a987af24 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -26,6 +26,7 @@ import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; import { Config } from "../../../config/Config.ts"; import { FailToGetOpenIdToken, + SFUTokenRefusedError, MatrixRTCTransportMissingError, NoMatrix2AuthorizationService, } from "../../../utils/errors.ts"; @@ -261,7 +262,9 @@ async function doOpenIdAndJWTFromUrl( function mapAuthErrorToUserFriendlyError(e: unknown): Error { if ( e instanceof FailToGetOpenIdToken || - e instanceof NoMatrix2AuthorizationService + e instanceof NoMatrix2AuthorizationService || + // [lotus] carries the token service's own refusal reason — keep it. + e instanceof SFUTokenRefusedError ) { // rethrow as is return e;