Author SHA1 Message Date
Lotus CIandClaude Opus 5 d881833491 chore(lotus): 0.25.0-lotus.6
CI / Build embedded bundle (push) Failing after 56s
CI / Publish to Gitea npm registry (push) Skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:04:45 -04:00
Lotus CIandClaude Opus 5 021b1881e5 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
2026-09-19 14:04:45 -04:00
Lotus CIandClaude Opus 5 1b609d997b chore(lotus): 0.25.0-lotus.5
CI / Build embedded bundle (push) Successful in 1m47s
CI / Publish to Gitea npm registry (push) Successful in 1m2s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:42:22 -04:00
Lotus CIandClaude Opus 5 d9ac9a0fa4 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
2026-09-19 13:42:22 -04:00
7 changed files with 102 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.4",
"version": "0.25.0-lotus.6",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+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.",
"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.",
"sfu_token_refused": "Can't join this call",
"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.",
"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 { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test";
import { FailToGetOpenIdToken } from "../utils/errors";
import { FailToGetOpenIdToken, SFUTokenRefusedError } from "../utils/errors";
const sfuUrl = "https://sfu.example.org";
@@ -91,6 +91,31 @@ describe("getSFUConfigWithOpenID", () => {
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 () => {
let callCount = 0;
+10
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
type IOpenIDToken,
type MatrixClient,
MatrixError,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -16,6 +17,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
import {
FailToGetOpenIdToken,
NoMatrix2AuthorizationService,
SFUTokenRefusedError,
} from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
@@ -165,6 +167,14 @@ export async function getSFUConfigWithOpenID(
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} 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(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
);
@@ -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;
+20
View File
@@ -23,6 +23,8 @@ export enum ErrorCode {
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
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",
SFU_ERROR = "SFU_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.
*/
/**
* [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 {
public constructor() {
super(