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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-19 14:04:45 -04:00
co-authored by Claude Opus 5
parent 1b609d997b
commit 021b1881e5
2 changed files with 44 additions and 1 deletions
@@ -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<CallMembership[]>([])),
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({
@@ -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;