Add enableClientWellKnownLookups

Gate client well-known lookups on enableClientWellKnownLookups

New URL param (default on), threaded from the embedder. When off, always
avoid client .well-known: skip the post-login poll (widget + SPA) and the
legacy MatrixRTC foci fallback. When on/unset, respect the transports
endpoint response: only fall back to .well-known on a 404 M_UNRECOGNIZED.
This commit is contained in:
David Langley
2026-07-10 18:49:10 +01:00
parent 59999b9b37
commit 23a9e0c85e
6 changed files with 212 additions and 74 deletions

View File

@@ -187,6 +187,13 @@ export interface UrlConfiguration {
*/
allowIceFallback: boolean;
/**
* Whether the app may make client `.well-known` lookups against the user's
* homeserver `server_name` (the post-login well-known poll and the legacy
* MatrixRTC foci `.well-known` fallback). Defaults to true.
*/
enableClientWellKnownLookups: boolean;
/**
* Whether the app should use per participant keys for E2EE.
*/
@@ -371,6 +378,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
enableClientWellKnownLookups: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
@@ -426,6 +434,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
enableClientWellKnownLookups: true,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
@@ -472,6 +481,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
showControls: parser.getFlag("showControls"),
hideScreensharing: parser.getFlag("hideScreensharing"),
allowIceFallback: parser.getFlag("allowIceFallback"),
enableClientWellKnownLookups: parser.getFlag("enableClientWellKnownLookups"),
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
skipLobby: isWidget ? parser.getFlag("skipLobby") : false,

View File

@@ -44,6 +44,7 @@ import {
} from "../../../livekit/openIDSFU.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { customLivekitUrl } from "../../../settings/settings.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
const logger = rootLogger.getChild("[LocalTransport]");
@@ -147,6 +148,7 @@ export const createLocalTransport$ = ({
const transportDiscovery = new RtcTransportAutoDiscovery({
client: client,
resolvedConfig: Config.get(),
enableClientWellKnownLookups: getUrlParams().enableClientWellKnownLookups,
wellKnownFetcher: AutoDiscovery.getRawClientConfig.bind(AutoDiscovery),
logger: logger,
});

View File

@@ -38,6 +38,11 @@ const wellKnownTransport: LivekitTransportConfig = {
livekit_service_url: "https://well-known.example.org",
};
const configTransport: LivekitTransportConfig = {
type: "livekit",
livekit_service_url: "https://config.example.org",
};
function makeClient(): MockedObject<DiscoveryClient> {
return {
getDomain: vi.fn().mockReturnValue("example.org"),
@@ -65,10 +70,29 @@ function makeWellKnown(rtcFoci?: Transport[]): IClientWellKnown {
} as unknown as IClientWellKnown;
}
// Error returned by a homeserver that does not implement the endpoint. This is
// the only failure that permits falling back to the legacy `.well-known` lookup.
const notImplementedError = new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404);
function makeDiscovery(
overrides: Partial<RtcTransportAutoDiscoveryProps> & {
client: DiscoveryClient;
wellKnownFetcher: RtcTransportAutoDiscoveryProps["wellKnownFetcher"];
},
): RtcTransportAutoDiscovery {
return new RtcTransportAutoDiscovery({
resolvedConfig: makeResolvedConfig("https://config.example.org"),
enableClientWellKnownLookups: true,
logger: rootLogger,
...overrides,
});
}
describe("RtcTransportAutoDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [
{ transports: [backendTransport] },
// will pick the first livekit transport in the list, even if there are other non-livekit transports
@@ -77,7 +101,6 @@ describe("RtcTransportAutoDiscovery", () => {
it.each(VALID_TEST_CASES)(
"prefers backend transport over well-known and app config $transports",
async ({ transports }) => {
// it("prefers backend transport over well-known and app config", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
@@ -85,12 +108,7 @@ describe("RtcTransportAutoDiscovery", () => {
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(
discovery.discoverPreferredTransport(),
@@ -120,12 +138,7 @@ describe("RtcTransportAutoDiscovery", () => {
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
backendTransport,
@@ -135,12 +148,15 @@ describe("RtcTransportAutoDiscovery", () => {
expect(wellKnownFetcher).not.toHaveBeenCalled();
});
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
// A homeserver that implements the endpoint but returns no usable livekit
// transport has answered authoritatively, so we must NOT leak to the legacy
// `.well-known` lookup. Instead we fall straight through to app config.
const AUTHORITATIVE_EMPTY_CASES: Array<{ transports: Transport[] }> = [
{ transports: [] },
{ transports: [{ type: "not_livekit" }] },
];
it.each(INVALID_TEST_CASES)(
"falls back to well-known when backend has no (valid) livekit transports $transports",
it.each(AUTHORITATIVE_EMPTY_CASES)(
"does not fall back to well-known when the backend answers without a livekit transport $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
@@ -149,21 +165,54 @@ describe("RtcTransportAutoDiscovery", () => {
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(
discovery.discoverPreferredTransport(),
).resolves.toStrictEqual(wellKnownTransport);
).resolves.toStrictEqual(configTransport);
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
expect(wellKnownFetcher).not.toHaveBeenCalled();
},
);
it("falls back to well-known only when the backend returns 404 M_UNRECOGNIZED", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockRejectedValue(notImplementedError);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
wellKnownTransport,
);
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
});
it("does not fall back to well-known when the backend fails with a non-M_UNRECOGNIZED errcode", async () => {
const client = makeClient();
// Same 404 status as the not-implemented case, but a different errcode: only
// M_UNRECOGNIZED is treated as "endpoint not implemented".
client._unstable_getRTCTransports.mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN" }, 404),
);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
configTransport,
);
expect(wellKnownFetcher).not.toHaveBeenCalled();
});
it("skips backend discovery in widget mode and uses well-known", async () => {
const client = makeClient();
// widget mode is detected by the absence of an access token
@@ -173,12 +222,7 @@ describe("RtcTransportAutoDiscovery", () => {
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
wellKnownTransport,
@@ -188,44 +232,58 @@ describe("RtcTransportAutoDiscovery", () => {
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
});
it("falls back to app config when backend fails and well-known has no rtc_foci", async () => {
it("does not make well-known lookups when disabled by config, even in widget mode", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN" }, 404),
// widget mode: backend endpoint is not attempted
client.getAccessToken.mockReturnValue(null);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = makeDiscovery({
client,
wellKnownFetcher,
enableClientWellKnownLookups: false,
});
// The legacy well-known fallback is skipped entirely; only app config is used.
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
configTransport,
);
expect(wellKnownFetcher).not.toHaveBeenCalled();
});
it("falls back to app config when the backend is not implemented and well-known has no rtc_foci", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockRejectedValue(notImplementedError);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue({} as IClientWellKnown);
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discovery = makeDiscovery({ client, wellKnownFetcher });
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
{
type: "livekit",
livekit_service_url: "https://config.example.org",
},
configTransport,
);
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
});
it("returns null when backend, well-known and config are all unavailable", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([]);
client._unstable_getRTCTransports.mockRejectedValue(notImplementedError);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue({} as IClientWellKnown);
const discovery = new RtcTransportAutoDiscovery({
const discovery = makeDiscovery({
client,
resolvedConfig: makeResolvedConfig(undefined),
wellKnownFetcher,
logger: rootLogger,
resolvedConfig: makeResolvedConfig(undefined),
});
await expect(discovery.discoverPreferredTransport()).resolves.toBeNull();

View File

@@ -8,7 +8,11 @@ import {
isLivekitTransportConfig,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type IClientWellKnown, type MatrixClient } from "matrix-js-sdk";
import {
type IClientWellKnown,
type MatrixClient,
MatrixError,
} from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
@@ -22,13 +26,36 @@ type TransportDiscoveryClient = Pick<
export interface RtcTransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
resolvedConfig: ResolvedConfigOptions;
/**
* Whether client `.well-known` lookups against the homeserver's `server_name`
* are allowed. When false, the legacy MatrixRTC foci `.well-known` fallback is
* skipped entirely and only the backend endpoint and app config are used.
*/
enableClientWellKnownLookups: boolean;
wellKnownFetcher: (domain: string) => Promise<IClientWellKnown>;
logger: Logger;
}
/**
* The outcome of querying the backend `/rtc/transports` endpoint.
*/
interface BackendTransportResult {
/** The livekit transport found via the backend endpoint, if any. */
transport: LivekitTransportConfig | null;
/**
* Whether the caller may fall back to the legacy `.well-known` lookup. True
* only when the backend endpoint is unavailable, i.e. it was not attempted
* (widget mode) or the homeserver does not implement it (404 M_UNRECOGNIZED).
* False when the endpoint answered authoritatively (a response without a
* livekit transport) or failed with any other error.
*/
mayFallBackToWellKnown: boolean;
}
export class RtcTransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly resolvedConfig: ResolvedConfigOptions;
private readonly enableClientWellKnownLookups: boolean;
private readonly wellKnownFetcher: (
domain: string,
) => Promise<IClientWellKnown>;
@@ -37,38 +64,53 @@ export class RtcTransportAutoDiscovery {
public constructor({
client,
resolvedConfig,
enableClientWellKnownLookups,
wellKnownFetcher,
logger,
}: RtcTransportAutoDiscoveryProps) {
this.client = client;
this.resolvedConfig = resolvedConfig;
this.enableClientWellKnownLookups = enableClientWellKnownLookups;
this.wellKnownFetcher = wellKnownFetcher;
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
}
public async discoverPreferredTransport(): Promise<LivekitTransportConfig | null> {
// 1) backend transports
const backendTransport = await this.tryBackendTransports();
if (backendTransport) {
const backend = await this.tryBackendTransports();
if (backend.transport) {
this.logger.info(
`Found backend transport: ${backendTransport.livekit_service_url}`,
`Found backend transport: ${backend.transport.livekit_service_url}`,
);
return backendTransport;
return backend.transport;
}
this.logger.info("No backend transport found, falling back to well-known");
// 2) .well-known transports
const wellKnownTransport = await this.tryWellKnownTransports();
if (wellKnownTransport) {
// Only consulted when the backend endpoint was inconclusive (not attempted
// or not implemented) and client `.well-known` lookups are enabled. This
// avoids contacting the homeserver's `server_name` both when a modern
// endpoint has already given an authoritative answer and when lookups are
// disabled by config.
if (backend.mayFallBackToWellKnown && this.enableClientWellKnownLookups) {
this.logger.info(
`Found .well-known transport: ${wellKnownTransport.livekit_service_url}`,
"No backend transport found, falling back to well-known",
);
const wellKnownTransport = await this.tryWellKnownTransports();
if (wellKnownTransport) {
this.logger.info(
`Found .well-known transport: ${wellKnownTransport.livekit_service_url}`,
);
return wellKnownTransport;
}
} else {
this.logger.info(
this.enableClientWellKnownLookups
? "Skipping .well-known lookup: backend endpoint gave an authoritative response"
: "Skipping .well-known lookup: client well-known lookups are disabled",
);
return wellKnownTransport;
}
this.logger.info(
"No .well-known transport found, falling back to app config",
);
this.logger.info("Falling back to app config");
// 3) app config URL
const configTransport = this.tryConfigTransport();
@@ -87,7 +129,7 @@ export class RtcTransportAutoDiscovery {
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
private async tryBackendTransports(): Promise<LivekitTransportConfig | null> {
private async tryBackendTransports(): Promise<BackendTransportResult> {
const client = this.client;
// MSC4143: Attempt to fetch transports from backend.
// TODO: Workaround for an issue in the js-sdk RoomWidgetClient that
@@ -104,21 +146,36 @@ export class RtcTransportAutoDiscovery {
);
const first = transportList.find(isLivekitTransportConfig);
if (first) {
return first;
} else {
this.logger.info(
`No livekit transport found in getRTCTransports end point`,
transportList,
);
return { transport: first, mayFallBackToWellKnown: false };
}
// The homeserver implements the endpoint but returned no livekit
// transport. This is an authoritative answer, so we do not fall back
// to the legacy `.well-known` lookup.
this.logger.info(
`No livekit transport found in getRTCTransports end point`,
transportList,
);
return { transport: null, mayFallBackToWellKnown: false };
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
// Only a 404 M_UNRECOGNIZED means the homeserver does not implement the
// endpoint, which is the one case where falling back to the legacy
// `.well-known` lookup is appropriate. Any other error is transient or
// unexpected, so we do not fall back.
if (ex instanceof MatrixError && ex.errcode === "M_UNRECOGNIZED") {
this.logger.info(
"getRTCTransports end point not implemented by homeserver",
);
return { transport: null, mayFallBackToWellKnown: true };
}
this.logger.warn(`Failed to use getRTCTransports end point: ${ex}`);
return { transport: null, mayFallBackToWellKnown: false };
}
} else {
this.logger.debug(`getRTCTransports end point not available`);
}
return null;
// Not attempted (e.g. widget mode with no access token). Preserve the
// existing behaviour of allowing the legacy `.well-known` lookup.
this.logger.debug(`getRTCTransports end point not available`);
return { transport: null, mayFallBackToWellKnown: true };
}
/**

View File

@@ -112,7 +112,7 @@ export async function initClient(
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
const { e2eEnabled, enableClientWellKnownLookups } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
@@ -166,7 +166,11 @@ export async function initClient(
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
// Leaving `clientWellKnownPollPeriod` unset stops the SDK polling the
// homeserver's `server_name` `.well-known` after login.
await client.startClient({
clientWellKnownPollPeriod: enableClientWellKnownLookups ? 60 * 10 : undefined,
});
await syncPromise;
return client;

View File

@@ -82,6 +82,7 @@ export const initializeWidget = (
baseUrl,
e2eEnabled,
allowIceFallback,
enableClientWellKnownLookups,
} = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
@@ -195,7 +196,13 @@ export const initializeWidget = (
// Wait for the config file to be ready (we load very early on so it might not
// be otherwise)
await Config.init();
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
// Leaving `clientWellKnownPollPeriod` unset stops the SDK polling the
// homeserver's `server_name` `.well-known` after login.
await client.startClient({
clientWellKnownPollPeriod: enableClientWellKnownLookups
? 60 * 10
: undefined,
});
return client;
};