Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/UrlParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ describe("UrlParams", () => {
});
});

describe("enableClientWellKnownLookups", () => {
it("is undefined when not set (so the config value applies)", () => {
expect(
computeUrlParams().enableClientWellKnownLookups,
).toBeUndefined();
});

it("is false when set to false", () => {
expect(
computeUrlParams("?enableClientWellKnownLookups=false")
.enableClientWellKnownLookups,
).toBe(false);
});

it("is true when set to true", () => {
expect(
computeUrlParams("?enableClientWellKnownLookups=true")
.enableClientWellKnownLookups,
).toBe(true);
});
});

describe("returnToLobby", () => {
it("is false in SPA mode", () => {
expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
Expand Down
9 changes: 9 additions & 0 deletions src/UrlParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ 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). Set by the embedder in widget mode;
* when unset the `enable_client_well_known_lookups` config value applies.
*/
enableClientWellKnownLookups?: boolean;

/**
* Whether the app should use per participant keys for E2EE.
*/
Expand Down Expand Up @@ -472,6 +480,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,
Expand Down
13 changes: 13 additions & 0 deletions src/config/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ export class Config {
return Config.get().default_server_config?.["m.homeserver"].server_name;
}

/**
* Whether the app may make client `.well-known` lookups against the
* homeserver `server_name`. The embedded (widget) deployment sets this via
* the `enableClientWellKnownLookups` URL parameter; the standalone (SPA)
* deployment sets it via `enable_client_well_known_lookups` in config.json.
*/
public static clientWellKnownLookupsEnabled(): boolean {
return (
getUrlParams().enableClientWellKnownLookups ??
Config.get().enable_client_well_known_lookups
);
}

public config?: ResolvedConfigOptions;
private initPromise?: Promise<void>;
}
Expand Down
12 changes: 12 additions & 0 deletions src/config/ConfigOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ export interface ConfigOptions {
};
};

/**
* 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). Set to false to keep the app on the
* homeserver base URL. This is the standalone (SPA) deployment control;
* embedded deployments set it via the `enableClientWellKnownLookups` URL
* parameter instead. Defaults to true.
*/
enable_client_well_known_lookups?: boolean;

// Describes the LiveKit configuration to be used.
livekit?: {
// The link to the service that returns a livekit url and token to use it.
Expand Down Expand Up @@ -189,6 +199,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
server_name: string;
};
};
enable_client_well_known_lookups: boolean;
sync_disconnect_grace_period_ms: number;
ssla: string;
matrix_rtc_session: {
Expand All @@ -211,6 +222,7 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: {
feature_use_device_session_member_events: true,
},
enable_client_well_known_lookups: true,
sync_disconnect_grace_period_ms: 10000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
matrix_rtc_session: {
Expand Down
1 change: 1 addition & 0 deletions src/state/CallViewModel/localMember/LocalTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
const transportDiscovery = new RtcTransportAutoDiscovery({
client: client,
resolvedConfig: Config.get(),
enableClientWellKnownLookups: Config.clientWellKnownLookupsEnabled(),
wellKnownFetcher: AutoDiscovery.getRawClientConfig.bind(AutoDiscovery),
logger: logger,
});
Expand Down Expand Up @@ -176,7 +177,7 @@
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
}
return config;
}),

Check failure on line 180 in src/state/CallViewModel/localMember/LocalTransport.ts

View workflow job for this annotation

GitHub Actions / Run unit tests

[unit] src/state/CallViewModel/localMember/LocalTransport.test.ts > LocalTransport > transport configuration mechanisms > supports getting transport via well-known

Error: Call is not supported ❯ src/state/CallViewModel/localMember/LocalTransport.ts:180:17 ❯ node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/operators/map.ts:57:32 ❯ OperatorSubscriber._this._next node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts:70:12 ❯ OperatorSubscriber.Subscriber.next node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/Subscriber.ts:71:11 ❯ node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/operators/switchMap.ts:114:41 ❯ OperatorSubscriber._this._next node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts:70:12 ❯ OperatorSubscriber.Subscriber.next node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/Subscriber.ts:71:11 ❯ node_modules/.pnpm/rxjs@7.8.2/node_modules/rxjs/src/internal/observable/innerFrom.ts:90:23 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { localisedTitle: 'Call is not supported', localisedMessage: 'The server is not configured to work with Element Call. Please contact your server admin (Domain: example.org, Error Code: MISSING_MATRIX_RTC_TRANSPORT).', category: 'CONFIGURATION_ISSUE', code: 'MISSING_MATRIX_RTC_TRANSPORT', domain: 'example.org' }
distinctUntilChanged(areLivekitTransportsEqual),
);

Expand Down
150 changes: 104 additions & 46 deletions src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand All @@ -77,20 +101,14 @@ 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);

const wellKnownFetcher = vi
.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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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({} as IClientWellKnown);
.mockResolvedValue(makeWellKnown([wellKnownTransport]));

const discovery = new RtcTransportAutoDiscovery({
const discovery = makeDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
enableClientWellKnownLookups: false,
});

// The legacy well-known fallback is skipped entirely; only app config is used.
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
{
type: "livekit",
livekit_service_url: "https://config.example.org",
},
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 = makeDiscovery({ client, wellKnownFetcher });

await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
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();
Expand Down
Loading
Loading