fix(lotus): show the SFU token service's refusal reason instead of "Something went wrong"

When the voice-limit guard (or any JWT service) answers 403 with a reason —
"This voice channel is full.", "You don't have permission to …" — EC wrapped it
in FailToGetOpenIdToken and the user saw the generic error page with
OPEN_ID_ERROR. A 403 MatrixError with a non-empty `error` now throws
SFUTokenRefusedError ("Can't join this call" + the server's sentence), so the
reason is the description. Other failures are unchanged. 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 13:42:22 -04:00
co-authored by Claude Opus 5
parent 66bfead7f1
commit d9ac9a0fa4
4 changed files with 57 additions and 1 deletions
+1
View File
@@ -116,6 +116,7 @@
"peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.", "peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.",
"room_creation_restricted": "Failed to create call", "room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.", "room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"sfu_token_refused": "Can't join this call",
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls", "sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.", "sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin." "unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
+26 -1
View File
@@ -20,7 +20,7 @@ import { MatrixError } from "matrix-js-sdk";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU"; import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures"; import { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test"; import { ownMemberMock } from "../utils/test";
import { FailToGetOpenIdToken } from "../utils/errors"; import { FailToGetOpenIdToken, SFUTokenRefusedError } from "../utils/errors";
const sfuUrl = "https://sfu.example.org"; const sfuUrl = "https://sfu.example.org";
@@ -91,6 +91,31 @@ describe("getSFUConfigWithOpenID", () => {
expect.fail("Expected test to throw;"); expect.fail("Expected test to throw;");
}); });
it("[lotus] surfaces a 403 refusal's reason instead of the generic error", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 403,
body: { errcode: "M_FORBIDDEN", error: "This voice channel is full." },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(SFUTokenRefusedError);
expect((ex as SFUTokenRefusedError).localisedMessage).toEqual(
"This voice channel is full.",
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => { it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0; let callCount = 0;
+10
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { import {
type IOpenIDToken, type IOpenIDToken,
type MatrixClient, type MatrixClient,
MatrixError,
parseErrorResponse, parseErrorResponse,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -16,6 +17,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
import { import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
NoMatrix2AuthorizationService, NoMatrix2AuthorizationService,
SFUTokenRefusedError,
} from "../utils/errors"; } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix"; import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
@@ -165,6 +167,14 @@ export async function getSFUConfigWithOpenID(
logger?.info(`Got JWT from call's active focus URL.`); logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig); return extractFullConfigFromToken(sfuConfig);
} catch (ex) { } catch (ex) {
// [lotus] A 403 from the token service carries the reason the user needs
// ("This voice channel is full.") — surface it instead of the generic error.
if (ex instanceof MatrixError && ex.httpStatus === 403) {
const reason = (ex.data as { error?: unknown } | undefined)?.error;
if (typeof reason === "string" && reason.trim()) {
throw new SFUTokenRefusedError(reason, ex);
}
}
throw new FailToGetOpenIdToken( throw new FailToGetOpenIdToken(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`), ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
); );
+20
View File
@@ -23,6 +23,8 @@ export enum ErrorCode {
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED", STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
OPEN_ID_ERROR = "OPEN_ID_ERROR", OPEN_ID_ERROR = "OPEN_ID_ERROR",
/** [lotus] The SFU token service refused us with a human-readable reason (e.g. the voice-limit guard: channel full / no permission). */
SFU_TOKEN_REFUSED = "SFU_TOKEN_REFUSED",
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE", NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
SFU_ERROR = "SFU_ERROR", SFU_ERROR = "SFU_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR", UNKNOWN_ERROR = "UNKNOWN_ERROR",
@@ -234,6 +236,24 @@ export class FailToStartLivekitConnection extends ElementCallError {
/** /**
* Error indicating that a LiveKit's server has hit its track limits. * Error indicating that a LiveKit's server has hit its track limits.
*/ */
/**
* [lotus] The SFU token service answered 403 with a reason we can show verbatim
* — the voice-limit guard says things like "This voice channel is full." or
* "You don't have permission to share your screen here." Without this the user
* only ever saw "Something went wrong (OPEN_ID_ERROR)".
*/
export class SFUTokenRefusedError extends ElementCallError {
public constructor(reason: string, cause?: Error) {
super(
t("error.sfu_token_refused"),
ErrorCode.SFU_TOKEN_REFUSED,
ErrorCategory.CONFIGURATION_ISSUE,
reason,
cause,
);
}
}
export class InsufficientCapacityError extends ElementCallError { export class InsufficientCapacityError extends ElementCallError {
public constructor() { public constructor() {
super( super(