mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Drop the auth modules main's PR 4331 added under src
The SDK replaced `src/livekit` and the remote-member modules before that PR landed on `main`, so its `src/livekit/auth/*` and `remoteMembers/ConnectionMap.ts` have no consumer here: the same code lives under `sdk/src/media-backend/livekit`. The imports of them that `main` added to the view model, the developer settings tab and the test utils go with them.
This commit is contained in:
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { extractFullConfigFromToken, type SFUConfig } from "./types.ts";
|
||||
import { doNetworkOperationWithRetry } from "../../utils/matrix.ts";
|
||||
|
||||
/**
|
||||
* In widget mode the host may refuse an action before any request is made
|
||||
* which surfaces as a plain error. Asking again cannot change its answer,
|
||||
* so only errors from the network are worth retrying.
|
||||
*/
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
return e instanceof HTTPError || e instanceof ConnectionError;
|
||||
}
|
||||
|
||||
export type ClientGetTokenParts = Pick<
|
||||
MatrixClient,
|
||||
"_unstable_getLivekitToken"
|
||||
>;
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: ClientGetTokenParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The WebSocket URL of the SFU for which we wish to get an access token.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The name of the homeserver to which the SFU belongs.
|
||||
*/
|
||||
serverName: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given SFU, using
|
||||
* the MSC4195 LiveKit client-server API endpoints.
|
||||
*/
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
const res = await doNetworkOperationWithRetry(
|
||||
async () =>
|
||||
client._unstable_getLivekitToken({
|
||||
url,
|
||||
server_name: serverName,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: membership.memberId,
|
||||
}),
|
||||
isNetworkError,
|
||||
);
|
||||
return extractFullConfigFromToken({ url, jwt: res.jwt });
|
||||
}
|
||||
|
||||
export type ClientDelegationParts = Pick<
|
||||
MatrixClient,
|
||||
"_unstable_delegateDelayedLeave" | "baseUrl"
|
||||
>;
|
||||
|
||||
export interface DelegateDelayedLeaveParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: ClientDelegationParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The WebSocket URL of the SFU to which we are publishing and wish to
|
||||
* delegate the delayed leave event.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated.
|
||||
*/
|
||||
delayId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates a delayed leave event to a given SFU, so that the event will
|
||||
* be sent on our behalf whenever we disconnect from the SFU.
|
||||
*/
|
||||
export async function delegateDelayedLeave({
|
||||
client,
|
||||
membership,
|
||||
url,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||
await doNetworkOperationWithRetry(
|
||||
async () =>
|
||||
client._unstable_delegateDelayedLeave({
|
||||
url,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: membership.memberId,
|
||||
delay_id: delayId,
|
||||
}),
|
||||
isNetworkError,
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, type Mocked, vi, describe, it } from "vitest";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
aliceDeviceId,
|
||||
aliceId,
|
||||
aliceUserId,
|
||||
createJWTToken,
|
||||
} from "../../utils/test-fixtures";
|
||||
import type * as csApi from "./csApi";
|
||||
import * as openID from "./openID";
|
||||
import { delegateDelayedLeave, getSFUConfig } from ".";
|
||||
import { mocked } from "storybook/test";
|
||||
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||
import { NoMatrix2AuthorizationService } from "../../utils/errors";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
vi.mock("./openID", () => ({ getSFUConfig: vi.fn() }));
|
||||
vi.useFakeTimers();
|
||||
|
||||
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
|
||||
const openIDJWTToken = createJWTToken(aliceId, "via OpenID");
|
||||
|
||||
mocked(openID).getSFUConfig.mockResolvedValue({
|
||||
url: "mock-openID-url",
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
livekitIdentity: aliceId,
|
||||
});
|
||||
|
||||
const client: Mocked<
|
||||
csApi.ClientGetTokenParts &
|
||||
csApi.ClientDelegationParts &
|
||||
openID.ClientOpenIDParts
|
||||
> = {
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
|
||||
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
|
||||
getOpenIdToken: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ access_token: "mock-openID-token" }),
|
||||
};
|
||||
|
||||
const membership: CallMembershipIdentityParts = {
|
||||
userId: aliceUserId,
|
||||
deviceId: aliceDeviceId,
|
||||
memberId: "123",
|
||||
};
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const slotId = "m.call#ROOM";
|
||||
|
||||
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
|
||||
|
||||
const urlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://livekit.example.org",
|
||||
};
|
||||
|
||||
const serviceUrlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://livekit-jwt.example.org",
|
||||
};
|
||||
|
||||
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
|
||||
...urlTransport,
|
||||
...serviceUrlTransport,
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("getSFUConfig", () => {
|
||||
const defaultParams = {
|
||||
client,
|
||||
membership,
|
||||
serverName: "example.org",
|
||||
role: "subscriber" as const,
|
||||
roomId,
|
||||
slotId,
|
||||
logger,
|
||||
};
|
||||
|
||||
it("uses CS API with url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: csApiJWTToken,
|
||||
livekitAlias: "via CS API",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID with url transport", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
await expect(
|
||||
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID with livekit_service_url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CS API with url + livekit_service_url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: csApiJWTToken,
|
||||
livekitAlias: "via CS API",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to OpenID with url + livekit_service_url transport", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to OpenID when the widget host refuses the CS API", async () => {
|
||||
// In widget mode a host without MSC4533 rejects the request before it
|
||||
// reaches the homeserver, with a plain error rather than a Matrix error
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"The rtc_livekit_get_token action is not supported by the client.",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: openIDJWTToken }));
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the widget host's refusal with url transport", async () => {
|
||||
const error = new Error(
|
||||
"The rtc_livekit_get_token action is not supported by the client.",
|
||||
);
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
|
||||
await expect(
|
||||
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow(error);
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CS API when publishing with url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlTransport,
|
||||
role: "publisher",
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID when publishing", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
await expect(
|
||||
getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlTransport,
|
||||
role: "publisher",
|
||||
}),
|
||||
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID when publishing with url + livekit_service_url transport", async () => {
|
||||
// Subscribers predating MSC4195 expect a legacy participant identity from
|
||||
// anyone advertising a state event, which only the JWT service assigns
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
role: "publisher",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: "publisher" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID on connection error", async () => {
|
||||
const client_ = {
|
||||
...client,
|
||||
_unstable_getLivekitToken: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new ConnectionError("No connection")),
|
||||
};
|
||||
await Promise.all([
|
||||
expect(
|
||||
getSFUConfig({
|
||||
...defaultParams,
|
||||
client: client_,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).rejects.toThrow(expect.any(ConnectionError)),
|
||||
vi.runAllTimersAsync(), // Allow request to be retried
|
||||
]);
|
||||
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegateDelayedLeave", () => {
|
||||
const defaultParams = {
|
||||
client,
|
||||
membership,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId: "delay123",
|
||||
logger,
|
||||
};
|
||||
|
||||
it("uses CS API with url transport", async () => {
|
||||
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
|
||||
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID with url transport", async () => {
|
||||
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
|
||||
unsupportedError,
|
||||
);
|
||||
await expect(
|
||||
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow();
|
||||
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID with livekit_service_url transport", async () => {
|
||||
await delegateDelayedLeave({
|
||||
...defaultParams,
|
||||
transport: serviceUrlTransport,
|
||||
});
|
||||
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID with url + livekit_service_url transport", async () => {
|
||||
// Must match the flow that got the token, so the identities line up
|
||||
await delegateDelayedLeave({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
});
|
||||
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "publisher",
|
||||
delayId: "delay123",
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import * as openID from "./openID";
|
||||
import * as csApi from "./csApi";
|
||||
import { type SFUConfig } from "./types";
|
||||
import { NoMatrix2AuthorizationService } from "../../utils/errors";
|
||||
|
||||
export type { ClientOpenIDParts } from "./openID";
|
||||
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
|
||||
export type * from "./types";
|
||||
|
||||
/**
|
||||
* Checks whether an error means "this homeserver does not implement the
|
||||
* (unstable) MSC4195 endpoints".
|
||||
*
|
||||
* MSC4195 documents `M_NOT_FOUND` for this, but a homeserver without support
|
||||
* answers with whatever it uses for unknown endpoints (Synapse: 404 with
|
||||
* `M_UNRECOGNIZED`), so we accept any 404 as "unsupported".
|
||||
* @param e The error to check.
|
||||
*/
|
||||
function isEndpointUnsupported(e: unknown): boolean {
|
||||
return (
|
||||
e instanceof MatrixError &&
|
||||
(e.httpStatus === 404 ||
|
||||
e.errcode === "M_NOT_FOUND" ||
|
||||
e.errcode === "M_UNRECOGNIZED")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an error means the MSC4195 CS API cannot be used from here:
|
||||
* the homeserver does not implement it, or the request never reached the
|
||||
* homeserver because in widget mode the host does not expose the endpoint
|
||||
* (MSC4533), which the widget API reports as a plain error rather than a
|
||||
* Matrix error. A connection error is neither, as the endpoint may well exist.
|
||||
* @param e The error to check.
|
||||
*/
|
||||
function isCsApiUnavailable(e: unknown): boolean {
|
||||
if (isEndpointUnsupported(e)) return true;
|
||||
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
|
||||
}
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The transport for which we wish to get an access token.
|
||||
*/
|
||||
transport: UnstableLivekitTransport;
|
||||
/**
|
||||
* The name of the homeserver to which the {@link transport} belongs.
|
||||
*/
|
||||
serverName: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on the {@link transport}.
|
||||
*/
|
||||
role: "publisher" | "subscriber";
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given transport.
|
||||
*/
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
transport,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
logger,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
|
||||
await openID.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
logger,
|
||||
});
|
||||
|
||||
const flow = chooseAuthFlow(transport, role);
|
||||
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
|
||||
|
||||
try {
|
||||
return await csApi.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
url: flow.url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
});
|
||||
} catch (e) {
|
||||
// Publishers should never fall back. If a homeserver advertises a `url`
|
||||
// but doesn't support MSC4195 itself, that's a misconfiguration which
|
||||
// the admin should know about so they can prepare for the future
|
||||
// deprecation of `livekit_service_url`.
|
||||
const mayFallBack =
|
||||
role === "subscriber" && "livekit_service_url" in transport;
|
||||
if (mayFallBack && isCsApiUnavailable(e)) {
|
||||
logger.warn(
|
||||
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
|
||||
);
|
||||
return await viaOpenID(transport.livekit_service_url);
|
||||
}
|
||||
if (isEndpointUnsupported(e)) {
|
||||
logger.error(
|
||||
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
|
||||
);
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DelegateDelayedLeaveParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The transport to which we are publishing and wish to delegate the delayed
|
||||
* leave event.
|
||||
*/
|
||||
transport: UnstableLivekitTransport;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated.
|
||||
*/
|
||||
delayId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates a delayed leave event to a given transport, so that the event will
|
||||
* be sent on our behalf whenever we disconnect from the transport's SFU.
|
||||
*/
|
||||
export async function delegateDelayedLeave({
|
||||
client,
|
||||
membership,
|
||||
transport,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
logger,
|
||||
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||
// It's important that we use the same flow (CS API or OpenID) as whatever we
|
||||
// would've used to get a JWT token for this transport, since the participant
|
||||
// identity computed by the service needs to match.
|
||||
const flow = chooseAuthFlow(transport, "publisher");
|
||||
if (flow.kind === "csApi") {
|
||||
try {
|
||||
await csApi.delegateDelayedLeave({
|
||||
client,
|
||||
membership,
|
||||
url: flow.url,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isEndpointUnsupported(e))
|
||||
logger.error(
|
||||
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// This will technically cause the service to issue a new JWT token, but
|
||||
// it's safe to discard. We're only interested in triggering delegation.
|
||||
await openID.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl: flow.serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role: "publisher",
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
delayId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow with which the local member authenticates against a transport.
|
||||
*/
|
||||
export type AuthFlow =
|
||||
| { kind: "csApi"; url: string }
|
||||
| { kind: "openID"; serviceUrl: string };
|
||||
|
||||
/**
|
||||
* Chooses between the MSC4195 CS API and the legacy OpenID flow against the
|
||||
* transport's JWT service.
|
||||
*
|
||||
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
|
||||
* identity that clients predating MSC4195 expect from anyone advertising a
|
||||
* membership state event. So a publisher keeps using the JWT service wherever
|
||||
* the transport still offers one, even if it also has a `url`. Subscribers may
|
||||
* use the CS API regardless, as nobody matches their identity.
|
||||
* @param transport The transport to authenticate against.
|
||||
* @param role Whether we want to publish or only subscribe on the transport.
|
||||
*/
|
||||
export function chooseAuthFlow(
|
||||
transport: UnstableLivekitTransport,
|
||||
role: "publisher" | "subscriber",
|
||||
): AuthFlow {
|
||||
if ("url" in transport) {
|
||||
const useCsApi =
|
||||
!("livekit_service_url" in transport) || role === "subscriber";
|
||||
if (useCsApi) return { kind: "csApi", url: transport.url };
|
||||
}
|
||||
return { kind: "openID", serviceUrl: transport.livekit_service_url };
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
getSFUConfig,
|
||||
type GetSFUConfigParams,
|
||||
type ClientOpenIDParts,
|
||||
} from "./openID";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../../utils/test";
|
||||
import { FailToGetOpenIdToken } from "../../utils/errors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
const serviceUrl = "https://sfu.example.org";
|
||||
|
||||
const delayParams = {
|
||||
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||
delayId: "mock_delay_id",
|
||||
};
|
||||
|
||||
describe("getSFUConfig", () => {
|
||||
let matrixClient: MockedObject<ClientOpenIDParts>;
|
||||
let params: GetSFUConfigParams;
|
||||
beforeEach(() => {
|
||||
fetchMock.catch(404);
|
||||
matrixClient = { getOpenIdToken: vitest.fn() };
|
||||
params = {
|
||||
client: matrixClient,
|
||||
membership: ownMemberMock,
|
||||
serviceUrl: "https://sfu.example.org",
|
||||
roomId: "!example_room_id",
|
||||
slotId: "m.call#ROOM",
|
||||
role: "publisher",
|
||||
logger,
|
||||
};
|
||||
});
|
||||
afterEach(async () => {
|
||||
void (await fetchMock.flush());
|
||||
vitest.clearAllMocks();
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
it("should handle fetching a token", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
const config = await getSFUConfig(params);
|
||||
expect(config).toEqual({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
livekitIdentity: "@me:example.org:ABCDEF",
|
||||
livekitAlias: "!example_room_id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if the SFU errors", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
await getSFUConfig(params);
|
||||
} catch (ex: unknown) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = (ex as Error).cause as MatrixError;
|
||||
expect(mxError.message).toEqual(
|
||||
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
fetchMock.post(
|
||||
"https://sfu.example.org/sfu/get",
|
||||
(url, opts) => {
|
||||
callCount++;
|
||||
const body = JSON.parse(opts.body as string);
|
||||
|
||||
// First call: check if it has delay parts and return 400
|
||||
if (callCount === 1) {
|
||||
expect(body).toHaveProperty("delay_id", "mock_delay_id");
|
||||
return {
|
||||
status: 400,
|
||||
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
|
||||
};
|
||||
}
|
||||
|
||||
// Second call: check if delay parts were stripped and return success
|
||||
expect(body).not.toHaveProperty("delay_id");
|
||||
expect(body).not.toHaveProperty("delay_timeout");
|
||||
expect(body).not.toHaveProperty("delay_cs_api_url");
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Note: Assuming getSFUConfig eventually calls getLiveKitJWT
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
|
||||
expect(config.jwt).toBe(testJWTToken);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
|
||||
fetchMock.post(
|
||||
"https://sfu.example.org/sfu/get",
|
||||
(url, opts) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
|
||||
// Verify, that the request contains the expected delay parameters
|
||||
if (
|
||||
body.delay_id === "mock_delay_id" &&
|
||||
body.delay_timeout === 3600000 &&
|
||||
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
|
||||
) {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "Missing expected delay params" },
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
|
||||
// Prüfe das Ergebnis
|
||||
expect(config).toMatchObject({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use the default endpoint when subscribing", async () => {
|
||||
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
expect(body).toMatchObject({
|
||||
room_id: "!example_room_id",
|
||||
slot_id: "m.call#ROOM",
|
||||
member: {
|
||||
id: ownMemberMock.memberId,
|
||||
claimed_user_id: ownMemberMock.userId,
|
||||
claimed_device_id: ownMemberMock.deviceId,
|
||||
},
|
||||
});
|
||||
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
|
||||
});
|
||||
const config = await getSFUConfig({ ...params, role: "subscriber" });
|
||||
expect(config.jwt).toBe(testJWTToken);
|
||||
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail when subscribing if the default endpoint is missing", async () => {
|
||||
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
|
||||
// must not fall back to the legacy endpoint, which would hand them a
|
||||
// legacy identity.
|
||||
await expect(
|
||||
getSFUConfig({ ...params, role: "subscriber" }),
|
||||
).rejects.toThrow(FailToGetOpenIdToken);
|
||||
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
|
||||
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
|
||||
});
|
||||
|
||||
it("should retry fetching the openid token", async () => {
|
||||
let count = 0;
|
||||
matrixClient.getOpenIdToken.mockImplementation(async () => {
|
||||
count++;
|
||||
if (count < 2) {
|
||||
throw Error("Test failure");
|
||||
}
|
||||
return Promise.resolve({
|
||||
token_type: "Bearer",
|
||||
access_token: "foobar",
|
||||
matrix_server_name: "example.org",
|
||||
expires_in: 30,
|
||||
});
|
||||
});
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
expect(config).toEqual({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
livekitIdentity: "@me:example.org:ABCDEF",
|
||||
livekitAlias: "!example_room_id",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type IOpenIDToken,
|
||||
type MatrixClient,
|
||||
parseErrorResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { FailToGetOpenIdToken } from "../../utils/errors";
|
||||
import { doNetworkOperationWithRetry } from "../../utils/matrix";
|
||||
import { Config } from "../../config/Config";
|
||||
import { extractFullConfigFromToken, type SFUConfig } from "./types";
|
||||
|
||||
// The bits we need from MatrixClient
|
||||
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
client: ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The base URL of the LiveKit JWT service.
|
||||
*/
|
||||
serviceUrl: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on the {@link transport}.
|
||||
*/
|
||||
role: "publisher" | "subscriber";
|
||||
/**
|
||||
* The base URL of the Matrix homeserver, for delayed leave delegation.
|
||||
*/
|
||||
delayEndpointBaseUrl?: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated to the JWT service.
|
||||
*/
|
||||
delayId?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to the SFU behind a
|
||||
* given service URL, using the legacy OpenID flow.
|
||||
* @throws {@link FailToGetOpenIdToken}
|
||||
*/
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
delayEndpointBaseUrl,
|
||||
delayId,
|
||||
logger,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
let openIdToken: IOpenIDToken;
|
||||
try {
|
||||
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||
client.getOpenIdToken(),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new FailToGetOpenIdToken(
|
||||
error instanceof Error ? error : new Error("Unknown error"),
|
||||
);
|
||||
}
|
||||
logger.debug("Got openID token", openIdToken);
|
||||
let sfuConfig: { url: string; jwt: string } | undefined;
|
||||
|
||||
let endpoint: "default" | "legacy";
|
||||
if (role === "publisher") {
|
||||
// When publishing a legacy transport (one with a `livekit_service_url`),
|
||||
// subscribers will expect our participant identity to use the legacy
|
||||
// `@user_id:device_id` format. Only the legacy JWT service endpoint assigns
|
||||
// identities in this format, so we must use it.
|
||||
endpoint = "legacy";
|
||||
} else {
|
||||
// Use the default endpoint as it affords us slightly better metadata
|
||||
// protection (pseudonymous participant identities).
|
||||
endpoint = "default";
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
|
||||
);
|
||||
sfuConfig = await (
|
||||
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
|
||||
)(
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
openIdToken,
|
||||
delayEndpointBaseUrl,
|
||||
delayId,
|
||||
);
|
||||
logger.info(`Got JWT from call's active focus URL.`);
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
} catch (e) {
|
||||
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
|
||||
throw new FailToGetOpenIdToken(
|
||||
e instanceof Error ? e : new Error(`Unknown error ${e}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a JWT token appropriate for connecting to the SFU behind a given service
|
||||
* URL, using the legacy `/sfu/get` endpoint, which assigns us a legacy
|
||||
* (`<user_id>:<device_id>`) participant identity.
|
||||
*/
|
||||
async function getLiveKitJWTLegacy(
|
||||
membership: CallMembershipIdentityParts,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
_slotId: string, // Unused - the legacy endpoint is from before slots existed.
|
||||
openIDToken: IOpenIDToken,
|
||||
delayEndpointBaseUrl?: string,
|
||||
delayId?: string,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
interface IDelayParams {
|
||||
delay_id?: string;
|
||||
delay_timeout?: number;
|
||||
delay_cs_api_url?: string;
|
||||
}
|
||||
let bodyDalayParts: IDelayParams = {};
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout:
|
||||
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
|
||||
delay_cs_api_url: delayEndpointBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
|
||||
return await fetch(livekitServiceURL + "/sfu/get", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
|
||||
// However, the livekit room alias is provided as part of the JWT payload.
|
||||
room: matrixRoomId,
|
||||
openid_token: openIDToken,
|
||||
device_id: membership.deviceId,
|
||||
...delayParts,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
let response = await makeRequest(bodyDalayParts);
|
||||
|
||||
// Old service compatibility check
|
||||
const oldServiceDoesNotSupportDelayParts =
|
||||
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
|
||||
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
|
||||
if (oldServiceDoesNotSupportDelayParts) {
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.errcode === "M_BAD_JSON") {
|
||||
response = await makeRequest({});
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the error, treat as real error
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
class NotSupportedError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NotSupported";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a JWT token appropriate for connecting to the SFU behind a given service
|
||||
* URL, using the default `/get_token` endpoint, which assigns us a hashed
|
||||
* (pseudonymised) participant identity.
|
||||
*/
|
||||
async function getLiveKitJWT(
|
||||
membership: CallMembershipIdentityParts,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
slotId: string,
|
||||
openIDToken: IOpenIDToken,
|
||||
delayEndpointBaseUrl?: string,
|
||||
delayId?: string,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
const { userId, deviceId, memberId } = membership;
|
||||
|
||||
const body = {
|
||||
room_id: matrixRoomId,
|
||||
slot_id: slotId,
|
||||
openid_token: openIDToken,
|
||||
member: {
|
||||
id: memberId,
|
||||
claimed_user_id: userId,
|
||||
claimed_device_id: deviceId,
|
||||
},
|
||||
};
|
||||
|
||||
let bodyDalayParts = {};
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout:
|
||||
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
|
||||
delay_cs_api_url: delayEndpointBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
return await fetch(livekitServiceURL + "/get_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...body, ...bodyDalayParts }),
|
||||
});
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = "SFU Config fetch failed with status code " + res.status;
|
||||
if (res.status === 404) {
|
||||
throw new NotSupportedError(msg);
|
||||
} else {
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* Data identifying a transport and the homeserver to which it belongs, so
|
||||
* consumers know how to reach it.
|
||||
*/
|
||||
export interface TransportLocator {
|
||||
transport: UnstableLivekitTransport;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration and access tokens provided by the SFU on successful authentication.
|
||||
*/
|
||||
export interface SFUConfig {
|
||||
/**
|
||||
* The WebSocket URL of the LiveKit SFU. This is what we connect the LiveKit
|
||||
* room to. Note that this is NOT the JWT service URL
|
||||
* (`livekit_service_url`): the legacy JWT service returns this URL as part of
|
||||
* its response, while the CS-Api based flow requires us to know it upfront.
|
||||
*/
|
||||
url: string;
|
||||
jwt: string;
|
||||
livekitAlias: string;
|
||||
// NOTE: Currently unused.
|
||||
livekitIdentity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded details from the JWT.
|
||||
*/
|
||||
interface SFUJWTPayload {
|
||||
/**
|
||||
* Expiration time for the JWT.
|
||||
* Note: This value is in seconds since Unix epoch.
|
||||
*/
|
||||
exp: number;
|
||||
/**
|
||||
* Name of the instance which authored the JWT
|
||||
*/
|
||||
iss: string;
|
||||
/**
|
||||
* Time at which the JWT can start to be used.
|
||||
* Note: This value is in seconds since Unix epoch.
|
||||
*/
|
||||
nbf: number;
|
||||
/**
|
||||
* Subject. The Livekit alias in this context.
|
||||
*/
|
||||
sub: string;
|
||||
/**
|
||||
* The set of permissions for the user.
|
||||
*/
|
||||
video: {
|
||||
canPublish: boolean;
|
||||
canSubscribe: boolean;
|
||||
room: string;
|
||||
roomJoin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complements the SFU websocket url and the JWT with the information encoded in
|
||||
* the JWT payload itself.
|
||||
* @param sfuConfig The SFU websocket url and the JWT to connect with.
|
||||
* @returns The full SFU config, including the LiveKit alias and identity.
|
||||
*/
|
||||
export function extractFullConfigFromToken(sfuConfig: {
|
||||
url: string;
|
||||
jwt: string;
|
||||
}): SFUConfig {
|
||||
const [, payloadStr] = sfuConfig.jwt.split(".");
|
||||
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||
return {
|
||||
jwt: sfuConfig.jwt,
|
||||
url: sfuConfig.url,
|
||||
livekitAlias: payload.video.room,
|
||||
// NOTE: Currently unused.
|
||||
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
|
||||
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
|
||||
livekitIdentity: payload.sub,
|
||||
};
|
||||
}
|
||||
@@ -72,8 +72,6 @@ import {
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
import { getSFUConfig } from "../livekit/auth/index.ts";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* The state of MatrixRTC's media key rotation.
|
||||
|
||||
@@ -126,7 +126,6 @@ import {
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
import { type TransportLocator } from "../../livekit/auth";
|
||||
|
||||
/** How a call captures and encodes the tracks it publishes. */
|
||||
export interface CaptureSettings {
|
||||
|
||||
@@ -128,8 +128,6 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
};
|
||||
const room = mockMatrixRoom({
|
||||
client: new (class extends EventEmitter {
|
||||
public readonly baseUrl = "https://matrix.example.com";
|
||||
|
||||
public getUserId(): string | undefined {
|
||||
return localRtcMember.userId;
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
type ObservableScope,
|
||||
} from "../../ObservableScope.ts";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth/types.ts";
|
||||
|
||||
// Branded string type, so that only values returned by `keyFor` can be used
|
||||
// as keys
|
||||
export type TransportKey = string & { __transportKey: true };
|
||||
|
||||
/**
|
||||
* Converts a {@link TransportLocator} to a key suitable for identifying it in a
|
||||
* {@link Map} or {@link Set}. Locators with the same key share a connection.
|
||||
*/
|
||||
export function keyFor({
|
||||
transport,
|
||||
serverName,
|
||||
}: TransportLocator): TransportKey {
|
||||
// The server name only takes part in authentication for a transport with a
|
||||
// `url`, where our homeserver asks that server for the token. A JWT service
|
||||
// is reached directly, so members of several homeservers advertising the
|
||||
// same one must share a connection rather than each get their own.
|
||||
const hasUrl = "url" in transport;
|
||||
// Serialize the fields in a consistent order
|
||||
return JSON.stringify([
|
||||
hasUrl ? transport.url : null,
|
||||
"livekit_service_url" in transport ? transport.livekit_service_url : null,
|
||||
hasUrl ? serverName : null,
|
||||
]) as TransportKey;
|
||||
}
|
||||
|
||||
function removeDuplicateTransports(
|
||||
transports: TransportLocator[],
|
||||
): TransportLocator[] {
|
||||
// The first locator wins, so the local one stays first in the list
|
||||
const map = new Map<TransportKey, TransportLocator>();
|
||||
for (const transport of transports) {
|
||||
const key = keyFor(transport);
|
||||
if (!map.has(key)) map.set(key, transport);
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export class ConnectionMap {
|
||||
private readonly map = new Map<
|
||||
TransportKey,
|
||||
{ connection: Connection; participants: RemoteParticipant[] }
|
||||
>();
|
||||
|
||||
public constructor(private readonly logger?: Logger) {}
|
||||
|
||||
public add(connection: Connection, participants: RemoteParticipant[]): void {
|
||||
const key = keyFor(connection);
|
||||
const existing = this.map.get(key);
|
||||
if (!existing) {
|
||||
this.map.set(key, { connection, participants });
|
||||
} else {
|
||||
// Transports are deduplicated by key upstream, so this should never
|
||||
// happen; if it does, members may be matched against the wrong room.
|
||||
this.logger?.warn(
|
||||
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
|
||||
);
|
||||
existing.participants.push(...participants);
|
||||
}
|
||||
}
|
||||
|
||||
public getConnections(): Connection[] {
|
||||
return Array.from(this.map.values()).map(({ connection }) => connection);
|
||||
}
|
||||
|
||||
public getConnectionForTransport(
|
||||
transport: TransportLocator,
|
||||
): Connection | null {
|
||||
return this.map.get(keyFor(transport))?.connection ?? null;
|
||||
}
|
||||
|
||||
public getParticipantsForTransport(
|
||||
transport: TransportLocator,
|
||||
): RemoteParticipant[] {
|
||||
const existing = this.map.get(keyFor(transport));
|
||||
if (existing) {
|
||||
return existing.participants;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
localTransport: TransportLocator;
|
||||
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
|
||||
|
||||
logger: Logger;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic map of connections and their associated backend participants.
|
||||
* @param props - Configuration object
|
||||
* @param props.scope - The observable scope used by this object
|
||||
* @param props.connectionFactory - Used to create new connections
|
||||
* @param props.localTransport - The transport to publish local media on. (deduplicated with remoteTransports$)
|
||||
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport)
|
||||
* @param props.ownMembershipIdentity - The own membership identity to use.
|
||||
* @param props.logger - The logger to use.
|
||||
*/
|
||||
export function createConnectionMap$({
|
||||
scope,
|
||||
connectionFactory,
|
||||
localTransport,
|
||||
remoteTransports$,
|
||||
logger: parentLogger,
|
||||
ownMembershipIdentity,
|
||||
}: Props): Behavior<Epoch<ConnectionMap>> {
|
||||
const logger = parentLogger.getChild("[ConnectionMap]");
|
||||
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
||||
|
||||
/**
|
||||
* All transports currently represented in the connection map. This list does
|
||||
* not include duplicate transports.
|
||||
*/
|
||||
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
|
||||
scope.behavior(
|
||||
remoteTransports$.pipe(
|
||||
mapEpoch((remoteTransports) =>
|
||||
removeDuplicateTransports([localTransport, ...remoteTransports]),
|
||||
),
|
||||
tap((transports) =>
|
||||
logger.debug("localAndRemoteTransports$ = ", transports),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const localTransportKey = keyFor(localTransport);
|
||||
|
||||
/**
|
||||
* Connections for each transport in use by one or more session members.
|
||||
*/
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
generateItemsWithEpoch(
|
||||
"ConnectionMap connections$",
|
||||
function* (transports) {
|
||||
for (const transport of transports) {
|
||||
const role =
|
||||
keyFor(transport) === localTransportKey
|
||||
? ("publisher" as const)
|
||||
: ("subscriber" as const);
|
||||
yield {
|
||||
// The locator is an object, so a stable key stands in for it
|
||||
keys: [role, keyFor(transport)],
|
||||
data: transport,
|
||||
};
|
||||
}
|
||||
},
|
||||
(scope, locator$, role, _key) => {
|
||||
const { transport, serverName } = locator$.value;
|
||||
const connection = connectionFactory.createConnection(
|
||||
scope,
|
||||
role,
|
||||
transport,
|
||||
serverName,
|
||||
ownMembershipIdentity,
|
||||
logger,
|
||||
);
|
||||
// Start the connection immediately
|
||||
// Use connection state to track connection progress
|
||||
void connection.start();
|
||||
// TODO subscribe to connection state to retry or log issues?
|
||||
return connection;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return scope.behavior(
|
||||
connections$.pipe(
|
||||
switchMap(({ value: connections, epoch }) => {
|
||||
if (connections.length === 0)
|
||||
return of(new Epoch(new ConnectionMap(), epoch));
|
||||
|
||||
return combineLatest(
|
||||
// Map the connections to list of Observable<{connection, participants}>
|
||||
connections.map((connection) =>
|
||||
connection.remoteParticipants$.pipe(
|
||||
map((participants) => ({ connection, participants })),
|
||||
),
|
||||
),
|
||||
// Collect into a single ConnectionMap
|
||||
(...list) => {
|
||||
const map = new ConnectionMap(logger);
|
||||
for (const item of list)
|
||||
map.add(item.connection, item.participants);
|
||||
return new Epoch(map, epoch);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/** How often a view model reads a track's RTP statistics while it needs them. */
|
||||
export const statsIntervalMs = 1000;
|
||||
|
||||
Reference in New Issue
Block a user