Compare commits
49 Commits
v37.11.0
...
v38.0.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d046edcb2 | |||
| 2abf7ca795 | |||
| 2b46579bd8 | |||
| 6d42ed338e | |||
| ef080c25f9 | |||
| c8d7b458b2 | |||
| f1ba8a8775 | |||
| d21adf568a | |||
| dea184e9ec | |||
| e119bf9040 | |||
| c7f982e190 | |||
| 2e2dd628c1 | |||
| 5ac5a8a799 | |||
| 7d75ab417a | |||
| 3ca81e409a | |||
| 764fdb1d30 | |||
| c2d25d9377 | |||
| c4e1e0723e | |||
| 56b24c0bdc | |||
| aa7a709e3f | |||
| c57c47319e | |||
| 812d0aaef6 | |||
| 61e07633df | |||
| c7dbd6e33b | |||
| 556494b8f0 | |||
| bf3b4e81b2 | |||
| 2710600389 | |||
| ca168c494b | |||
| 759f5ed3eb | |||
| 53deedd2d6 | |||
| 4fbb9f92a7 | |||
| 1a58ce4649 | |||
| 32509d1fd1 | |||
| 11c9e39e5a | |||
| 0ce944f3da | |||
| 38e04c8fb0 | |||
| 1fcbc6ebeb | |||
| aa79236ce2 | |||
| f8f1bf3837 | |||
| c077201f2a | |||
| 53f2ad41d6 | |||
| be15a709c6 | |||
| 090b8079db | |||
| 06a1e1a88a | |||
| 024b62bba0 | |||
| 6bfb911cf7 | |||
| ad5da7cfa1 | |||
| e55bd1e14c | |||
| 119e859741 |
@@ -1,3 +1,41 @@
|
||||
Changes in [37.13.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.13.0) (2025-08-11)
|
||||
====================================================================================================
|
||||
This release supports new v12 Matrix rooms and consequently has a breaking change, removing powerLevelNorm from the RoomMember object as this can't be supported with infinite power levels. Apps should use the non-normalised `powerLevel` instead.
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* [Backport staging] Support v12 rooms in maySendEvent ([#4956](https://github.com/matrix-org/matrix-js-sdk/pull/4956)). Contributed by @RiotRobot.
|
||||
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
|
||||
* Experimental support for sharing encrypted history on invite ([#4920](https://github.com/matrix-org/matrix-js-sdk/pull/4920)). Contributed by @richvdh.
|
||||
* Use the logger associated with MatrixClient in rust sdk ([#4918](https://github.com/matrix-org/matrix-js-sdk/pull/4918)). Contributed by @richvdh.
|
||||
* Update to matrix-sdk-crypto-wasm 15.1.0, and add new `ShieldStateCode.MismatchedSender` ([#4916](https://github.com/matrix-org/matrix-js-sdk/pull/4916)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix unknown/broken state in the RTC Membership Manager causing unnecassary error logging. ([#4944](https://github.com/matrix-org/matrix-js-sdk/pull/4944)). Contributed by @toger5.
|
||||
|
||||
|
||||
Changes in [37.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.12.0) (2025-07-29)
|
||||
====================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* Deprecate non-functional `IJoinRoomOpts.syncRoom` ([#4913](https://github.com/matrix-org/matrix-js-sdk/pull/4913)). Contributed by @richvdh.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Custom abort timeout logic for restarting delayed events that is compatible with the widget api ([#4927](https://github.com/matrix-org/matrix-js-sdk/pull/4927)). Contributed by @toger5.
|
||||
* Allow sending notification events when starting a call ([#4826](https://github.com/matrix-org/matrix-js-sdk/pull/4826)). Contributed by @robintown.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix deep import incompatibility (#4924) ([#4925](https://github.com/matrix-org/matrix-js-sdk/pull/4925)). Contributed by @toriningen.
|
||||
* Fix more incorrect logger use ([#4904](https://github.com/matrix-org/matrix-js-sdk/pull/4904)). Contributed by @richvdh.
|
||||
|
||||
|
||||
Changes in [37.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.11.0) (2025-07-15)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -119,7 +119,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
|
||||
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
|
||||
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
|
||||
|
||||
1. Note that an explicit type is optional if not expected to be used outside of the function call,
|
||||
unlike in this example:
|
||||
|
||||
@@ -153,7 +152,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
28. Export only what can be reused.
|
||||
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
|
||||
of truly optional parameters.
|
||||
|
||||
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
|
||||
takes an argument that is more often not required than required. An example where the
|
||||
`?` operator is inappropriate is when taking a room ID: typically the caller should
|
||||
@@ -272,7 +270,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
interfaces have a habit of becoming more complex over time.
|
||||
|
||||
3. Inside a function, there is no need to comment every line, but consider:
|
||||
|
||||
- before a particular multiline section of code within the function, give an overview of what it does,
|
||||
to make it easier for a reader to follow the flow through the function as a whole.
|
||||
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.11.0",
|
||||
"version": "38.0.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -49,7 +49,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.0.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/jest": "^29.0.0",
|
||||
@@ -115,7 +115,7 @@
|
||||
"lint-staged": "^16.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier": "3.6.2",
|
||||
"rimraf": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.28.1",
|
||||
|
||||
@@ -17,9 +17,10 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import debug from "debug";
|
||||
|
||||
import { syncPromise } from "../../test-utils/test-utils";
|
||||
import { type AuthDict, createClient, type MatrixClient } from "../../../src";
|
||||
import { type AuthDict, createClient, DebugLogger, type MatrixClient } from "../../../src";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts";
|
||||
import { type CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
|
||||
@@ -91,6 +92,7 @@ describe("cross-signing", () => {
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
logger: new DebugLogger(debug(`matrix-js-sdk:cross-signing`)),
|
||||
});
|
||||
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
@@ -135,9 +137,9 @@ describe("cross-signing", () => {
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
// check that the cross-signing keys have been uploaded
|
||||
expect(fetchMock.called("upload-cross-signing-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-cross-signing-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
@@ -223,9 +225,6 @@ describe("cross-signing", () => {
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
|
||||
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
|
||||
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
||||
@@ -418,15 +417,18 @@ describe("cross-signing", () => {
|
||||
return new Promise<any>((resolve) => {
|
||||
fetchMock.post(
|
||||
{
|
||||
url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
url: new URL(
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
aliceClient.getHomeserverUrl(),
|
||||
).toString(),
|
||||
name: "upload-cross-signing-keys",
|
||||
},
|
||||
(url, options) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content);
|
||||
return {};
|
||||
},
|
||||
// Override the routes define in `mockSetupCrossSigningRequests`
|
||||
// Override the route defined in E2EKeyReceiver
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,9 @@ limitations under the License.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import debug from "debug";
|
||||
|
||||
import { ClientEvent, createClient, type MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { ClientEvent, createClient, DebugLogger, type MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { CryptoEvent } from "../../../src/crypto-api/index";
|
||||
import { type RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { type AddSecretStorageKeyOpts } from "../../../src/secret-storage";
|
||||
@@ -38,6 +39,7 @@ describe("Device dehydration", () => {
|
||||
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
|
||||
},
|
||||
},
|
||||
logger: new DebugLogger(debug(`matrix-js-sdk:dehydration`)),
|
||||
});
|
||||
|
||||
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
|
||||
@@ -179,8 +181,6 @@ async function initializeSecretStorage(
|
||||
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
|
||||
const accountData: Map<string, object> = new Map();
|
||||
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import mkDebug from "debug";
|
||||
|
||||
import {
|
||||
createClient,
|
||||
DebugLogger,
|
||||
EventType,
|
||||
type IContent,
|
||||
KnownMembership,
|
||||
type MatrixClient,
|
||||
MsgType,
|
||||
} from "../../../src";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder.ts";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts";
|
||||
import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
|
||||
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
||||
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
|
||||
import { escapeRegExp } from "../../../src/utils.ts";
|
||||
|
||||
const debug = mkDebug("matrix-js-sdk:history-sharing");
|
||||
|
||||
// load the rust library. This can take a few seconds on a slow GH worker.
|
||||
beforeAll(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-wasm");
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
}, 10000);
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const ROOM_ID = "!room:example.com";
|
||||
const ALICE_HOMESERVER_URL = "https://alice-server.com";
|
||||
const BOB_HOMESERVER_URL = "https://bob-server.com";
|
||||
|
||||
async function createAndInitClient(homeserverUrl: string, userId: string) {
|
||||
mockInitialApiRequests(homeserverUrl, userId);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: userId,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "xzcvb",
|
||||
logger: new DebugLogger(mkDebug(`matrix-js-sdk:${userId}`)),
|
||||
});
|
||||
|
||||
await client.initRustCrypto({ cryptoDatabasePrefix: userId });
|
||||
await client.startClient();
|
||||
await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("History Sharing", () => {
|
||||
let aliceClient: MatrixClient;
|
||||
let aliceSyncResponder: SyncResponder;
|
||||
let bobClient: MatrixClient;
|
||||
let bobSyncResponder: SyncResponder;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const aliceId = "@alice:localhost";
|
||||
const bobId = "@bob:xyz";
|
||||
|
||||
const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-");
|
||||
const aliceKeyResponder = new E2EKeyResponder(ALICE_HOMESERVER_URL);
|
||||
const aliceKeyClaimResponder = new E2EOTKClaimResponder(ALICE_HOMESERVER_URL);
|
||||
aliceSyncResponder = new SyncResponder(ALICE_HOMESERVER_URL);
|
||||
|
||||
const bobKeyReceiver = new E2EKeyReceiver(BOB_HOMESERVER_URL, "bob-");
|
||||
const bobKeyResponder = new E2EKeyResponder(BOB_HOMESERVER_URL);
|
||||
bobSyncResponder = new SyncResponder(BOB_HOMESERVER_URL);
|
||||
|
||||
aliceKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
|
||||
aliceKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
|
||||
bobKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
|
||||
bobKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
|
||||
|
||||
aliceClient = await createAndInitClient(ALICE_HOMESERVER_URL, aliceId);
|
||||
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobId);
|
||||
|
||||
aliceKeyClaimResponder.addKeyReceiver(bobId, bobClient.deviceId!, bobKeyReceiver);
|
||||
|
||||
aliceSyncResponder.sendOrQueueSyncResponse({});
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
bobSyncResponder.sendOrQueueSyncResponse({});
|
||||
await syncPromise(bobClient);
|
||||
});
|
||||
|
||||
test("Room keys are successfully shared on invite", async () => {
|
||||
// Alice is in an encrypted room
|
||||
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
|
||||
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// ... and she sends an event
|
||||
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
|
||||
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hi!" });
|
||||
const sentMessage = await msgProm;
|
||||
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
|
||||
|
||||
// Now, Alice invites Bob
|
||||
const uploadProm = new Promise<Uint8Array>((resolve) => {
|
||||
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => {
|
||||
const body = request.body as Uint8Array;
|
||||
debug(`Alice uploaded blob of length ${body.length}`);
|
||||
resolve(body);
|
||||
return { content_uri: "mxc://alice-server/here" };
|
||||
});
|
||||
});
|
||||
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
|
||||
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
|
||||
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
|
||||
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
|
||||
const uploadedBlob = await uploadProm;
|
||||
const sentToDeviceRequest = await toDeviceMessageProm;
|
||||
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
|
||||
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
|
||||
expect(bobToDeviceMessage).toBeDefined();
|
||||
|
||||
// Bob receives the to-device event and the room invite
|
||||
const inviteEvent = mkEventCustom({
|
||||
type: "m.room.member",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
state_key: bobClient.getSafeUserId(),
|
||||
content: { membership: KnownMembership.Invite },
|
||||
});
|
||||
bobSyncResponder.sendOrQueueSyncResponse({
|
||||
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
|
||||
to_device: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
content: bobToDeviceMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await syncPromise(bobClient);
|
||||
|
||||
const room = bobClient.getRoom(ROOM_ID);
|
||||
expect(room).toBeTruthy();
|
||||
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
|
||||
|
||||
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
|
||||
room_id: ROOM_ID,
|
||||
});
|
||||
fetchMock.getOnce(
|
||||
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
|
||||
{ body: uploadedBlob },
|
||||
{ sendAsJson: false },
|
||||
);
|
||||
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
|
||||
|
||||
// Bob receives, should be able to decrypt, the megolm message
|
||||
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
|
||||
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
|
||||
mkEventCustom({
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
content: sentMessage,
|
||||
event_id: "$event_id",
|
||||
}) as any,
|
||||
);
|
||||
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
|
||||
await syncPromise(bobClient);
|
||||
|
||||
const bobRoom = bobClient.getRoom(ROOM_ID);
|
||||
const event = bobRoom!.getLastLiveEvent()!;
|
||||
expect(event.getId()).toEqual("$event_id");
|
||||
await event.getDecryptionPromise();
|
||||
expect(event.getType()).toEqual("m.room.message");
|
||||
expect(event.getContent().body).toEqual("Hi!");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
bobClient.stopClient();
|
||||
aliceClient.stopClient();
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise<IContent> {
|
||||
return new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/rooms/[^/]*/send/${escapeRegExp(msgtype)}/`),
|
||||
(url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content);
|
||||
return { event_id: "$event_id" };
|
||||
},
|
||||
{ name: "sendRoomEvent" },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function expectSendToDeviceMessage(
|
||||
homeserverUrl: string,
|
||||
msgtype: string,
|
||||
): Promise<Record<string, Record<string, object>>> {
|
||||
return new Promise((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/sendToDevice/${escapeRegExp(msgtype)}/`),
|
||||
(url: string, opts: RequestInit) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
resolve(body.messages);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import anotherjson from "another-json";
|
||||
import debug from "debug";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { createHash } from "crypto";
|
||||
@@ -25,6 +26,7 @@ import Olm from "@matrix-org/olm";
|
||||
import type FetchMock from "fetch-mock";
|
||||
import {
|
||||
createClient,
|
||||
DebugLogger,
|
||||
DeviceVerification,
|
||||
type IContent,
|
||||
type ICreateClientOpts,
|
||||
@@ -1475,6 +1477,7 @@ describe("verification", () => {
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "device_under_test",
|
||||
logger: new DebugLogger(debug(`matrix-js-sdk:verification`)),
|
||||
...opts,
|
||||
});
|
||||
await client.initRustCrypto();
|
||||
|
||||
@@ -267,6 +267,59 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invite", function () {
|
||||
it("should send request to /invite", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const userId = "@user:server";
|
||||
|
||||
httpBackend
|
||||
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ user_id: userId });
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client.invite(roomId, userId);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
});
|
||||
|
||||
it("accepts a stringy reason argument", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const userId = "@user:server";
|
||||
|
||||
httpBackend
|
||||
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ user_id: userId, reason: "testreason" });
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client.invite(roomId, userId, "testreason");
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
});
|
||||
|
||||
it("accepts an options object with a reason", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const userId = "@user:server";
|
||||
|
||||
httpBackend
|
||||
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ user_id: userId, reason: "testreason" });
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client.invite(roomId, userId, { reason: "testreason" });
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
});
|
||||
});
|
||||
|
||||
describe("knockRoom", function () {
|
||||
const roomId = "!some-room-id:example.org";
|
||||
const reason = "some reason";
|
||||
|
||||
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import debugFunc from "debug";
|
||||
import { type Debugger } from "debug";
|
||||
import debugFunc, { type Debugger } from "debug";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
|
||||
import type { CrossSigningKeys, ISignedKey, KeySignatures } from "../../src";
|
||||
import type { CrossSigningKeyInfo } from "../../src/crypto-api";
|
||||
|
||||
/** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys
|
||||
*
|
||||
@@ -55,19 +56,27 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
private readonly debug: Debugger;
|
||||
|
||||
private deviceKeys: IDeviceKeys | null = null;
|
||||
private crossSigningKeys: CrossSigningKeys | null = null;
|
||||
private oneTimeKeys: Record<string, IOneTimeKey> = {};
|
||||
private readonly oneTimeKeysPromise: Promise<void>;
|
||||
|
||||
/**
|
||||
* Construct a new E2EKeyReceiver.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl.
|
||||
* Only /upload requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* It will immediately register an intercept of [`/keys/upload`][1], [`/keys/signatures/upload`][2] and
|
||||
* [`/keys/device_signing/upload`][3] requests for the given homeserverUrl.
|
||||
* Only requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
* [1]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysupload
|
||||
* [2]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keyssignaturesupload
|
||||
* [3]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysdevice_signingupload
|
||||
*
|
||||
* @param homeserverUrl - The Homeserver Url of the client under test.
|
||||
* @param routeNamePrefix - An optional prefix to add to the fetchmock route names. Required if there is more than
|
||||
* one E2EKeyReceiver instance active.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
public constructor(homeserverUrl: string, routeNamePrefix: string = "") {
|
||||
this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`);
|
||||
|
||||
// set up a listener for /keys/upload.
|
||||
@@ -77,6 +86,22 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
|
||||
});
|
||||
|
||||
fetchMock.post(
|
||||
{
|
||||
url: new URL("/_matrix/client/v3/keys/signatures/upload", homeserverUrl).toString(),
|
||||
name: routeNamePrefix + "upload-sigs",
|
||||
},
|
||||
(url, options) => this.onSignaturesUploadRequest(options),
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
{
|
||||
url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(),
|
||||
name: routeNamePrefix + "upload-cross-signing-keys",
|
||||
},
|
||||
(url, options) => this.onSigningKeyUploadRequest(options),
|
||||
);
|
||||
}
|
||||
|
||||
private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise<object> {
|
||||
@@ -87,8 +112,10 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
if (this.deviceKeys) {
|
||||
throw new Error("Application attempted to upload E2E device keys multiple times");
|
||||
}
|
||||
this.debug(`received device keys`);
|
||||
this.deviceKeys = content.device_keys;
|
||||
this.debug(
|
||||
`received device keys for user ID ${this.deviceKeys!.user_id}, device ID ${this.deviceKeys!.device_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) {
|
||||
@@ -113,6 +140,47 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
};
|
||||
}
|
||||
|
||||
private async onSignaturesUploadRequest(request: RequestInit): Promise<object> {
|
||||
const content = JSON.parse(request.body as string) as KeySignatures;
|
||||
for (const [userId, userKeys] of Object.entries(content)) {
|
||||
for (const [deviceId, signedKey] of Object.entries(userKeys)) {
|
||||
this.onDeviceSignatureUpload(userId, deviceId, signedKey);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private onDeviceSignatureUpload(userId: string, deviceId: string, signedKey: CrossSigningKeyInfo | ISignedKey) {
|
||||
if (!this.deviceKeys || userId != this.deviceKeys.user_id || deviceId != this.deviceKeys.device_id) {
|
||||
this.debug(
|
||||
`Ignoring device key signature upload for unknown device user ID ${userId}, device ID ${deviceId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.debug(`received device key signature for user ID ${userId}, device ID ${deviceId}`);
|
||||
this.deviceKeys.signatures ??= {};
|
||||
for (const [signingUser, signatures] of Object.entries(signedKey.signatures!)) {
|
||||
this.deviceKeys.signatures[signingUser] = Object.assign(
|
||||
this.deviceKeys.signatures[signingUser] ?? {},
|
||||
signatures,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async onSigningKeyUploadRequest(request: RequestInit): Promise<object> {
|
||||
const content = JSON.parse(request.body as string);
|
||||
if (this.crossSigningKeys) {
|
||||
throw new Error("Application attempted to upload E2E cross-signing keys multiple times");
|
||||
}
|
||||
this.debug(`received cross-signing keys`);
|
||||
// Remove UIA data
|
||||
delete content["auth"];
|
||||
this.crossSigningKeys = content;
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Get the uploaded Ed25519 key
|
||||
*
|
||||
* If device keys have not yet been uploaded, throws an error
|
||||
@@ -150,6 +218,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
return this.deviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If cross-signing keys have been uploaded, return them. Else return null.
|
||||
*/
|
||||
public getUploadedCrossSigningKeys(): CrossSigningKeys | null {
|
||||
return this.crossSigningKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If one-time keys have already been uploaded, return them. Otherwise,
|
||||
* set up an expectation that the keys will be uploaded, and wait for
|
||||
@@ -161,4 +236,18 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
await this.oneTimeKeysPromise;
|
||||
return this.oneTimeKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If no one-time keys have yet been uploaded, return `null`.
|
||||
* Otherwise, pop a key from the uploaded list.
|
||||
*/
|
||||
public getOneTimeKey(): [string, IOneTimeKey] | null {
|
||||
const keys = Object.entries(this.oneTimeKeys);
|
||||
if (keys.length == 0) {
|
||||
return null;
|
||||
}
|
||||
const [otkId, otk] = keys[0];
|
||||
delete this.oneTimeKeys[otkId];
|
||||
return [otkId, otk];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MapWithDefault } from "../../src/utils";
|
||||
import { type IDownloadKeyResult } from "../../src";
|
||||
import { type IDownloadKeyResult, type SigningKeys } from "../../src";
|
||||
import { type IDeviceKeys } from "../../src/@types/crypto";
|
||||
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
|
||||
@@ -50,18 +50,14 @@ export class E2EKeyResponder {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const usersToReturn = Object.keys(content["device_keys"]);
|
||||
const response = {
|
||||
device_keys: {} as { [userId: string]: any },
|
||||
master_keys: {} as { [userId: string]: any },
|
||||
self_signing_keys: {} as { [userId: string]: any },
|
||||
user_signing_keys: {} as { [userId: string]: any },
|
||||
failures: {} as { [serverName: string]: any },
|
||||
};
|
||||
device_keys: {},
|
||||
master_keys: {},
|
||||
self_signing_keys: {},
|
||||
user_signing_keys: {},
|
||||
failures: {},
|
||||
} as IDownloadKeyResult;
|
||||
for (const user of usersToReturn) {
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] = Object.fromEntries(userKeys.entries());
|
||||
}
|
||||
|
||||
// First see if we have an E2EKeyReceiver for this user, and if so, return any keys that have been uploaded
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
||||
if (e2eKeyReceiver !== undefined) {
|
||||
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
||||
@@ -69,16 +65,27 @@ export class E2EKeyResponder {
|
||||
response.device_keys[user] ??= {};
|
||||
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
|
||||
}
|
||||
const crossSigningKeys = e2eKeyReceiver.getUploadedCrossSigningKeys();
|
||||
if (crossSigningKeys !== null) {
|
||||
response.master_keys![user] = crossSigningKeys["master_key"];
|
||||
response.self_signing_keys![user] = crossSigningKeys["self_signing_key"] as SigningKeys;
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in any keys that have been added explicitly to this E2EKeyResponder.
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] ??= {};
|
||||
Object.assign(response.device_keys[user], Object.fromEntries(userKeys.entries()));
|
||||
}
|
||||
if (this.masterKeysByUser.hasOwnProperty(user)) {
|
||||
response.master_keys[user] = this.masterKeysByUser[user];
|
||||
response.master_keys![user] = this.masterKeysByUser[user];
|
||||
}
|
||||
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
|
||||
response.self_signing_keys![user] = this.selfSigningKeysByUser[user];
|
||||
}
|
||||
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
|
||||
response.user_signing_keys![user] = this.userSigningKeysByUser[user];
|
||||
}
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MapWithDefault } from "../../src/utils";
|
||||
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
import { type IClaimKeysRequest } from "../../src";
|
||||
|
||||
/**
|
||||
* An object which intercepts `/keys/claim` fetches via fetch-mock.
|
||||
*/
|
||||
export class E2EOTKClaimResponder {
|
||||
private e2eKeyReceiversByUserByDevice = new MapWithDefault<string, Map<string, E2EKeyReceiver>>(() => new Map());
|
||||
|
||||
/**
|
||||
* Construct a new E2EOTKClaimResponder.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/claim` requests for the given homeserverUrl.
|
||||
* Only /claim requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
const listener = (url: string, options: RequestInit) => this.onKeyClaimRequest(options);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/claim", homeserverUrl).toString(), listener);
|
||||
}
|
||||
|
||||
private onKeyClaimRequest(options: RequestInit) {
|
||||
const content = JSON.parse(options.body as string) as IClaimKeysRequest;
|
||||
const response = {
|
||||
one_time_keys: {} as { [userId: string]: any },
|
||||
};
|
||||
for (const [userId, devices] of Object.entries(content["one_time_keys"])) {
|
||||
for (const deviceId of Object.keys(devices)) {
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUserByDevice.get(userId)?.get(deviceId);
|
||||
const otk = e2eKeyReceiver?.getOneTimeKey();
|
||||
if (otk) {
|
||||
const [keyId, key] = otk;
|
||||
response.one_time_keys[userId] ??= {};
|
||||
response.one_time_keys[userId][deviceId] = {
|
||||
[keyId]: key,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an E2EKeyReceiver to poll for uploaded keys
|
||||
*
|
||||
* When the `/keys/claim` request is received, a OTK will be removed from the `E2EKeyReceiver` and
|
||||
* added to the response.
|
||||
*/
|
||||
public addKeyReceiver(userId: string, deviceId: string, e2eKeyReceiver: E2EKeyReceiver) {
|
||||
this.e2eKeyReceiversByUserByDevice.getOrCreate(userId).set(deviceId, e2eKeyReceiver);
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,9 @@ export function mockInitialApiRequests(homeserverUrl: string, userId: string = "
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
* Mock the requests needed to set up cross signing, besides those provided by {@link E2EKeyReceiver}.
|
||||
*
|
||||
* Return 404 error for `GET _matrix/client/v3/user/:userId/account_data/:type` request
|
||||
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
|
||||
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
|
||||
*/
|
||||
export function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
@@ -55,19 +53,6 @@ export function mockSetupCrossSigningRequests(): void {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import mkdebug from "debug";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type EventEmitter from "events";
|
||||
import { logger } from "../../src/logger";
|
||||
import {
|
||||
type IContent,
|
||||
type IEvent,
|
||||
@@ -24,6 +25,8 @@ import { eventMapperFor } from "../../src/event-mapper";
|
||||
import { TEST_ROOM_ID } from "./test-data";
|
||||
import { KnownMembership, type Membership } from "../../src/@types/membership";
|
||||
|
||||
const debug = mkdebug("test-utils");
|
||||
|
||||
/**
|
||||
* Return a promise that is resolved when the client next emits a
|
||||
* SYNCING event.
|
||||
@@ -38,7 +41,7 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
||||
|
||||
const p = new Promise<void>((resolve) => {
|
||||
const cb = (state: SyncState) => {
|
||||
logger.log(`${Date.now()} syncPromise(${count}): ${state}`);
|
||||
debug(`syncPromise(${count}): ${state}`);
|
||||
if (state === SyncState.Syncing) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -519,25 +522,25 @@ export async function awaitDecryption(
|
||||
// already
|
||||
if (event.getClearContent() !== null) {
|
||||
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
|
||||
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
|
||||
debug(`event ${event.getId()} got decryption error; waiting`);
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
} else {
|
||||
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
|
||||
debug(`event ${event.getId()} is not yet decrypted; waiting`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (waitOnDecryptionFailure) {
|
||||
event.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
debug(`MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
if (!err) {
|
||||
resolve(ev);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
debug(`MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
}
|
||||
@@ -589,3 +592,98 @@ export async function advanceTimersUntil<T>(promise: Promise<T>): Promise<T> {
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
export function jestFakeTimersAreEnabled(): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(setTimeout, "clock");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `callback` in a loop, until it returns a successful result (i.e. it does not throw), or we reach a timeout
|
||||
*
|
||||
* Based on the function of the same name in the {@link https://testing-library.com/docs/dom-testing-library/api-async/#waitfor DOM testing library}.
|
||||
*
|
||||
* @param callback - The function to call to check if we can proceed. If it returns a result (including a falsey one),
|
||||
* `waitFor` returns that result. If it throws, `waitFor` continues to wait.
|
||||
*
|
||||
* May return a promise, in which case no further checks are done until the promise resolves.
|
||||
*
|
||||
* @param timeout - The time to wait for, overall, in ms. If `callback` still hasn't returned a successful result after
|
||||
* this time, `waitFor` will throw an error.
|
||||
*
|
||||
* Defaults to 1000.
|
||||
*
|
||||
* @param interval - How often to call `callback`. Defaults to 50.
|
||||
*/
|
||||
export function waitFor<T>(
|
||||
callback: () => Promise<T> | T,
|
||||
{
|
||||
timeout = 1000,
|
||||
interval = 50,
|
||||
}: {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let lastError: any;
|
||||
let finished = false;
|
||||
let intervalId: ReturnType<typeof setTimeout> | undefined;
|
||||
let promisePending = false;
|
||||
|
||||
const overallTimeoutTimer = setTimeout(handleTimeout, timeout);
|
||||
const usingJestFakeTimers = jestFakeTimersAreEnabled();
|
||||
if (usingJestFakeTimers) {
|
||||
checkCallback();
|
||||
|
||||
while (!finished) {
|
||||
jest.advanceTimersByTime(interval);
|
||||
|
||||
// Could have timed-out
|
||||
if (finished) break;
|
||||
|
||||
checkCallback();
|
||||
}
|
||||
} else {
|
||||
intervalId = setInterval(checkCallback, interval);
|
||||
checkCallback();
|
||||
}
|
||||
|
||||
function checkCallback() {
|
||||
if (promisePending) {
|
||||
// still waiting for the previous check
|
||||
return;
|
||||
}
|
||||
|
||||
async function doCheck() {
|
||||
try {
|
||||
const result = await callback();
|
||||
onDone();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
// Save the most recent callback error to reject the promise with it in the event of a timeout
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
promisePending = true;
|
||||
doCheck().finally(() => {
|
||||
promisePending = false;
|
||||
});
|
||||
}
|
||||
|
||||
function onDone(): void {
|
||||
finished = true;
|
||||
clearTimeout(overallTimeoutTimer);
|
||||
if (intervalId !== undefined) clearInterval(intervalId);
|
||||
}
|
||||
|
||||
function handleTimeout() {
|
||||
onDone();
|
||||
if (lastError) {
|
||||
reject(lastError);
|
||||
} else {
|
||||
reject(new Error("Timed out in waitFor."));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,7 +120,15 @@ describe("FetchHttpApi", () => {
|
||||
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res);
|
||||
});
|
||||
|
||||
it("should return text if json=false", async () => {
|
||||
it("should set an Accept header, and parse the response as JSON, by default", async () => {
|
||||
const result = { a: 1 };
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(result) });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(result);
|
||||
expect(fetchFn.mock.calls[0][1].headers.Accept).toBe("application/json");
|
||||
});
|
||||
|
||||
it("should not set an Accept header, and should return text if json=false", async () => {
|
||||
const text = "418 I'm a teapot";
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
@@ -129,6 +137,31 @@ describe("FetchHttpApi", () => {
|
||||
json: false,
|
||||
}),
|
||||
).resolves.toBe(text);
|
||||
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("should not set an Accept header, and should return a blob, if rawResponseBody is true", async () => {
|
||||
const blob = new Blob(["blobby"]);
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, blob: jest.fn().mockResolvedValue(blob) });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
await expect(
|
||||
api.requestOtherUrl(Method.Get, "http://url", undefined, {
|
||||
rawResponseBody: true,
|
||||
}),
|
||||
).resolves.toBe(blob);
|
||||
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("should throw an error if both `json` and `rawResponseBody` are defined", async () => {
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn: jest.fn(),
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(
|
||||
api.requestOtherUrl(Method.Get, "http://url", undefined, { rawResponseBody: false, json: true }),
|
||||
).rejects.toThrow("Invalid call to `FetchHttpApi`");
|
||||
});
|
||||
|
||||
it("should send token via query params if useAuthorizationHeader=false", async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
|
||||
import loglevel from "loglevel";
|
||||
|
||||
import { logger } from "../../src/logger.ts";
|
||||
import { DebugLogger, logger } from "../../src/logger.ts";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
@@ -49,3 +49,42 @@ describe("logger", () => {
|
||||
expect(console.debug).toHaveBeenCalledWith("[prefix1][prefix2]", "test2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DebugLogger", () => {
|
||||
it("should handle empty log messages", () => {
|
||||
const mockTarget = jest.fn();
|
||||
const logger = new DebugLogger(mockTarget as any);
|
||||
logger.info();
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith("[INFO] ");
|
||||
});
|
||||
|
||||
it("should handle logging an Error", () => {
|
||||
const mockTarget = jest.fn();
|
||||
const logger = new DebugLogger(mockTarget as any);
|
||||
|
||||
// If there is a stack and a message, we use the stack.
|
||||
const error = new Error("I am an error");
|
||||
logger.error(error);
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith(expect.stringMatching(/^\[ERROR\] Error: I am an error\n\s*at/));
|
||||
|
||||
mockTarget.mockClear();
|
||||
|
||||
// If there is only a message, we use that.
|
||||
error.stack = undefined;
|
||||
logger.error(error);
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith("[ERROR] I am an error");
|
||||
});
|
||||
|
||||
it("should handle logging an object", () => {
|
||||
const mockTarget = jest.fn();
|
||||
const logger = new DebugLogger(mockTarget as any);
|
||||
|
||||
const obj = { a: 1 };
|
||||
logger.warn(obj);
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith("[WARN] %O", obj);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,17 +16,18 @@ limitations under the License.
|
||||
|
||||
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
|
||||
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
const callSession = { id: "", application: "m.call" };
|
||||
|
||||
describe("MatrixRTCSession", () => {
|
||||
let client: MatrixClient;
|
||||
let sess: MatrixRTCSession | undefined;
|
||||
@@ -48,16 +49,35 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
describe("roomSessionForRoom", () => {
|
||||
it("creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom(membershipTemplate);
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].callId).toEqual("");
|
||||
expect(sess?.memberships[0].sessionDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.callId).toEqual("");
|
||||
expect(sess?.sessionDescription.id).toEqual("");
|
||||
});
|
||||
|
||||
it("ignores memberships where application is not m.call", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
application: "not-m.call",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships where callId is not empty", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
call_id: "not-empty",
|
||||
scope: "m.room",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
@@ -68,16 +88,16 @@ describe("MatrixRTCSession", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", () => {
|
||||
const mockRoom = makeMockRoom(membershipTemplate);
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
|
||||
@@ -88,14 +108,14 @@ describe("MatrixRTCSession", () => {
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -130,7 +150,7 @@ describe("MatrixRTCSession", () => {
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -165,7 +185,7 @@ describe("MatrixRTCSession", () => {
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -173,7 +193,7 @@ describe("MatrixRTCSession", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -181,23 +201,7 @@ describe("MatrixRTCSession", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no scope", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.scope as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores anything that's not a room-scoped call (for now)", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
testMembership.scope = "m.user";
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -212,7 +216,7 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -237,7 +241,7 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
@@ -257,7 +261,7 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
@@ -270,15 +274,21 @@ describe("MatrixRTCSession", () => {
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
|
||||
let sentStateEvent: Promise<void>;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
sentStateEvent = new Promise((resolve) => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
sendEventMock = jest.fn().mockResolvedValue(undefined);
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
|
||||
client._unstable_updateDelayedEvent = jest.fn();
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -298,12 +308,70 @@ describe("MatrixRTCSession", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a notification when starting a call", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
const ownMembershipId = sess?.memberships[0].eventId;
|
||||
|
||||
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": "ring",
|
||||
"m.relates_to": {
|
||||
event_id: ownMembershipId,
|
||||
rel_type: "org.matrix.msc4075.rtc.notification.parent",
|
||||
},
|
||||
"lifetime": 30000,
|
||||
"sender_ts": expect.any(Number),
|
||||
});
|
||||
|
||||
// Check if deprecated notify event is also sent.
|
||||
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
|
||||
"application": "m.call",
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notify_type": "ring",
|
||||
"call_id": "",
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't send a notification when joining an existing call", async () => {
|
||||
// Add another member to the call so that it is considered an existing call
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
|
||||
// Simulate a join, including the update to the room state
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
|
||||
expect(client.sendEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("doesn't send a notification when someone else starts the call faster than us", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
// But this time we want to simulate a race condition in which we receive a state event
|
||||
// from someone else, starting the call before our own state event has been sent
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
|
||||
// We assume that the responsibility to send a notification, if any, lies with the other
|
||||
// participant that won the race
|
||||
expect(client.sendEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onMembershipsChanged", () => {
|
||||
it("does not emit if no membership changes", () => {
|
||||
const mockRoom = makeMockRoom(membershipTemplate);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
@@ -313,13 +381,13 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("emits on membership changes", () => {
|
||||
const mockRoom = makeMockRoom(membershipTemplate);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, []);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalled();
|
||||
@@ -369,7 +437,7 @@ describe("MatrixRTCSession", () => {
|
||||
client.encryptAndSendToDevice = sendToDeviceMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -488,7 +556,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
const mockRoom = makeMockRoom([membershipTemplate, member2]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
// joining will trigger an initial key send
|
||||
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
@@ -503,18 +571,14 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
|
||||
|
||||
// member2 leaves triggering key rotation
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
// member2 re-joins which should trigger an immediate re-send
|
||||
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, [membershipTemplate, member2]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
// but, that immediate resend is throttled so we need to wait a bit
|
||||
jest.advanceTimersByTime(1000);
|
||||
@@ -541,7 +605,7 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
@@ -565,9 +629,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, [membershipTemplate, member2]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
@@ -592,11 +654,9 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, [member1, member2]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
@@ -641,11 +701,7 @@ describe("MatrixRTCSession", () => {
|
||||
};
|
||||
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
@@ -674,6 +730,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
// update created_ts
|
||||
member2.created_ts = 5000;
|
||||
mockRoomState(mockRoom, [member1, member2]);
|
||||
|
||||
const keysSentPromise2 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
@@ -708,7 +765,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
const mockRoom = makeMockRoom([membershipTemplate, member2]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
const onMyEncryptionKeyChanged = jest.fn();
|
||||
sess.on(
|
||||
@@ -737,9 +794,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
jest.advanceTimersByTime(KEY_DELAY);
|
||||
@@ -784,7 +839,7 @@ describe("MatrixRTCSession", () => {
|
||||
it("wraps key index around to 0 when it reaches the maximum", async () => {
|
||||
// this should give us keys with index [0...255, 0, 1]
|
||||
const membersToTest = 258;
|
||||
const members: SessionMembershipData[] = [];
|
||||
const members: MembershipData[] = [];
|
||||
for (let i = 0; i < membersToTest; i++) {
|
||||
members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` }));
|
||||
}
|
||||
@@ -800,15 +855,11 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
if (i === 0) {
|
||||
// if first time around then set up the session
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
} else {
|
||||
// otherwise update the state reducing the membership each time in order to trigger key rotation
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
makeMockRoomState(members.slice(0, membersToTest - i), mockRoom.roomId),
|
||||
);
|
||||
mockRoomState(mockRoom, members.slice(0, membersToTest - i));
|
||||
}
|
||||
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
@@ -830,7 +881,7 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
@@ -849,9 +900,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
|
||||
mockRoomState(mockRoom, [membershipTemplate, member2]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
@@ -873,7 +922,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
@@ -896,7 +945,7 @@ describe("MatrixRTCSession", () => {
|
||||
describe("receiving", () => {
|
||||
it("collects keys from encryption events", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
@@ -921,7 +970,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("collects keys at non-zero indices", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
@@ -947,7 +996,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("collects keys by merging", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
@@ -998,7 +1047,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("ignores older keys at same index", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
@@ -1057,7 +1106,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("key timestamps are treated as monotonic", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
@@ -1101,7 +1150,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
@@ -1124,7 +1173,7 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
// defaults to getTs()
|
||||
jest.setSystemTime(1000);
|
||||
|
||||
@@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mock } from "jest-mock";
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
@@ -49,24 +48,102 @@ describe("MatrixRTCSessionManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom(membershipTemplate);
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
(room1.getLiveTimeline as Mock).mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(makeMockRoomState([{}], room1.roomId)),
|
||||
});
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("")[0];
|
||||
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Fires correctly with for with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
||||
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
|
||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,8 @@ function createAsyncHandle<T>(method: MockedFunction<any>) {
|
||||
return { reject, resolve };
|
||||
}
|
||||
|
||||
const callSession = { id: "", application: "m.call" };
|
||||
|
||||
describe("MembershipManager", () => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
@@ -81,7 +83,7 @@ describe("MembershipManager", () => {
|
||||
// Default to fake timers.
|
||||
jest.useFakeTimers();
|
||||
client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
room = makeMockRoom(membershipTemplate);
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
@@ -95,12 +97,12 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
@@ -114,7 +116,7 @@ describe("MembershipManager", () => {
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
memberManager.join([focus], focusActive);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
@@ -130,7 +132,7 @@ describe("MembershipManager", () => {
|
||||
focus_active: focusActive,
|
||||
scope: "m.room",
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
@@ -138,13 +140,13 @@ describe("MembershipManager", () => {
|
||||
{ delay: 8000 },
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
@@ -189,7 +191,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
|
||||
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`;
|
||||
// preparing the delayed disconnect should handle ratelimiting
|
||||
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
|
||||
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
|
||||
@@ -220,6 +222,7 @@ describe("MembershipManager", () => {
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
@@ -276,7 +279,7 @@ describe("MembershipManager", () => {
|
||||
describe("delayed leave event", () => {
|
||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
@@ -288,7 +291,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
@@ -300,6 +303,7 @@ describe("MembershipManager", () => {
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
@@ -307,7 +311,7 @@ describe("MembershipManager", () => {
|
||||
{ delay: 123456 },
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -319,6 +323,7 @@ describe("MembershipManager", () => {
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
// Join with the membership manager
|
||||
manager.join([focus], focusActive);
|
||||
@@ -351,7 +356,13 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("uses membershipEventExpiryMs from config", async () => {
|
||||
const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined);
|
||||
const manager = new MembershipManager(
|
||||
{ membershipEventExpiryMs: 1234567 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
@@ -370,12 +381,12 @@ describe("MembershipManager", () => {
|
||||
type: "livekit",
|
||||
},
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -387,7 +398,7 @@ describe("MembershipManager", () => {
|
||||
describe("leave()", () => {
|
||||
// TODO add rate limit cases.
|
||||
it("resolves delayed leave event when leave is called", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
@@ -395,7 +406,7 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
@@ -406,11 +417,11 @@ describe("MembershipManager", () => {
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
});
|
||||
it("does nothing if not joined", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -420,7 +431,7 @@ describe("MembershipManager", () => {
|
||||
describe("getsActiveFocus", () => {
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const getOldestMembership = jest.fn();
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership);
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
|
||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -436,11 +447,11 @@ describe("MembershipManager", () => {
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
device_id: client.getDeviceId(),
|
||||
user_id: client.getUserId()!,
|
||||
device_id: client.getDeviceId()!,
|
||||
created_ts: 1000,
|
||||
},
|
||||
room.roomId,
|
||||
client.getUserId()!,
|
||||
),
|
||||
);
|
||||
expect(manager.getActiveFocus()).toStrictEqual(focus);
|
||||
@@ -455,7 +466,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
@@ -463,7 +474,7 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -471,7 +482,7 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||
@@ -482,7 +493,10 @@ describe("MembershipManager", () => {
|
||||
|
||||
await manager.onRTCSessionMemberUpdate([
|
||||
mockCallMembership(membershipTemplate, room.roomId),
|
||||
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
|
||||
mockCallMembership(
|
||||
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
|
||||
room.roomId,
|
||||
),
|
||||
]);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
@@ -492,7 +506,7 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
@@ -508,6 +522,32 @@ describe("MembershipManager", () => {
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Not sure about this name
|
||||
@@ -518,6 +558,7 @@ describe("MembershipManager", () => {
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
@@ -549,6 +590,7 @@ describe("MembershipManager", () => {
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
@@ -571,14 +613,14 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected'", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
@@ -592,7 +634,7 @@ describe("MembershipManager", () => {
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -608,7 +650,7 @@ describe("MembershipManager", () => {
|
||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -635,7 +677,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||
// RateLimit error.
|
||||
manager.join([focus], focusActive);
|
||||
@@ -655,7 +697,7 @@ describe("MembershipManager", () => {
|
||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
handle.reject?.(
|
||||
new MatrixError(
|
||||
@@ -690,7 +732,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
// Hit rate limit
|
||||
@@ -723,7 +765,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -743,7 +785,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -754,7 +796,7 @@ describe("MembershipManager", () => {
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
@@ -768,6 +810,7 @@ describe("MembershipManager", () => {
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
for (let retries = 0; retries < 7; retries++) {
|
||||
@@ -785,7 +828,7 @@ describe("MembershipManager", () => {
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
@@ -797,9 +840,9 @@ describe("MembershipManager", () => {
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom(membershipTemplate);
|
||||
const room = makeMockRoom([membershipTemplate]);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
|
||||
+333
-35
@@ -24,7 +24,8 @@ import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
import { logger, type Logger } from "../../../src/logger.ts";
|
||||
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
// The manager being tested
|
||||
@@ -61,6 +62,7 @@ describe("RTCEncryptionManager", () => {
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -82,12 +84,13 @@ describe("RTCEncryptionManager", () => {
|
||||
encryptionManager.join(undefined);
|
||||
// After join it is too early, key might be lost as no one is listening yet
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should distribute keys to members on join", async () => {
|
||||
jest.useFakeTimers();
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
@@ -96,7 +99,7 @@ describe("RTCEncryptionManager", () => {
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
@@ -120,7 +123,7 @@ describe("RTCEncryptionManager", () => {
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
@@ -135,7 +138,7 @@ describe("RTCEncryptionManager", () => {
|
||||
},
|
||||
],
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
|
||||
@@ -147,7 +150,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
// There are no membership change but the callMembership ts has changed (reset?)
|
||||
// Resend the key
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
@@ -165,7 +168,7 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should not rotate key when a user join", async () => {
|
||||
it("Should not rotate key when a user join within the rotation grace period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
@@ -174,10 +177,11 @@ describe("RTCEncryptionManager", () => {
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
const gracePeriod = 15_000; // 15 seconds
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
@@ -189,14 +193,10 @@ describe("RTCEncryptionManager", () => {
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate(updatedMembers);
|
||||
// Carl joins, within the grace period
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
await jest.advanceTimersByTimeAsync(gracePeriod / 2);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
@@ -214,6 +214,154 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
// Test an edge case where the use key delay is higher than the grace period.
|
||||
// This means that no matter what, the key once rolled out will be too old to be re-used for the new member that
|
||||
// joined within the grace period.
|
||||
// So we expect another rotation to happen in all cases where a new member joins.
|
||||
it("test grace period lower than delay period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
const gracePeriod = 3_000; // 3 seconds
|
||||
const useKeyDelay = gracePeriod + 2_000; // 5 seconds
|
||||
// initial rollout
|
||||
encryptionManager.join({
|
||||
useKeyDelay,
|
||||
keyRotationGracePeriodMs: gracePeriod,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
// The existing members have been talking for 5mn
|
||||
await jest.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
// A new member joins, that should trigger a key rotation.
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
// A new member joins, within the grace period, but under the delay period
|
||||
members.push(aCallMembership("@david:example.org", "DAVDEVICE"));
|
||||
await jest.advanceTimersByTimeAsync((useKeyDelay - gracePeriod) / 2);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
// Wait past the delay period
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
// Even though the new member joined within the grace period, the key should be rotated because once the delay period has passed
|
||||
// also the grace period is exceeded/the key is too old to be reshared.
|
||||
|
||||
// CARLDEVICE should have received a key with index 1 and another one with index 2
|
||||
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 1, "@carl:example.org", "CARLDEVICE");
|
||||
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 2, "@carl:example.org", "CARLDEVICE");
|
||||
// Of course, should not have received the first key
|
||||
expectKeyAtIndexNotToHaveBeenSentTo(mockTransport, 0, "@carl:example.org", "CARLDEVICE");
|
||||
|
||||
// DAVDEVICE should only have received a key with index 2
|
||||
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 2, "@david:example.org", "DAVDEVICE");
|
||||
expectKeyAtIndexNotToHaveBeenSentTo(mockTransport, 1, "@david:example.org", "DAVDEVICE");
|
||||
});
|
||||
|
||||
it("Should rotate key when a user join past the rotation grace period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
const gracePeriod = 15_000; // 15 seconds
|
||||
// initial rollout
|
||||
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(gracePeriod + 1000);
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should have incremented the key index
|
||||
1,
|
||||
// And send it to everyone
|
||||
[
|
||||
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE" }),
|
||||
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE2" }),
|
||||
expect.objectContaining({ userId: "@carl:example.org", deviceId: "CARLDEVICE" }),
|
||||
],
|
||||
);
|
||||
|
||||
// Wait for useKeyDelay to pass
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not rotate key when several users join within the rotation grace period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
const newJoiners = [
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aCallMembership("@dave:example.org", "DAVEDEVICE"),
|
||||
aCallMembership("@eve:example.org", "EVEDEVICE"),
|
||||
aCallMembership("@frank:example.org", "FRANKDEVICE"),
|
||||
aCallMembership("@george:example.org", "GEORGEDEVICE"),
|
||||
];
|
||||
|
||||
for (const newJoiner of newJoiners) {
|
||||
members.push(newJoiner);
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(newJoiners.length);
|
||||
|
||||
for (const newJoiner of newJoiners) {
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should not have incremented the key index
|
||||
0,
|
||||
// And send it to the new joiners only
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ userId: newJoiner.sender, deviceId: newJoiner.deviceId }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should not resend keys when no changes", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -225,20 +373,20 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(50);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalled();
|
||||
@@ -255,7 +403,7 @@ describe("RTCEncryptionManager", () => {
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
@@ -276,7 +424,7 @@ describe("RTCEncryptionManager", () => {
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate(updatedMembers);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
// The is rotated but not rolled out yet to give time for the key to be sent
|
||||
@@ -300,6 +448,23 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not distribute keys if encryption is disabled", async () => {
|
||||
jest.useFakeTimers();
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join({ manageMediaKeys: false });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Receiving Keys", () => {
|
||||
@@ -323,6 +488,29 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not accept keys when manageMediaKeys is disabled", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join({ manageMediaKeys: false });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept keys from transport", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -334,7 +522,7 @@ describe("RTCEncryptionManager", () => {
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
@@ -428,6 +616,92 @@ describe("RTCEncryptionManager", () => {
|
||||
"@carol:example.org:CAROLDEVICE",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should store keys for later retrieval", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
1000,
|
||||
);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"CCCCCCCCCCC",
|
||||
5 /* KeyId */,
|
||||
1000,
|
||||
);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
"DDDDDDDDDDD",
|
||||
0 /* KeyId */,
|
||||
1000,
|
||||
);
|
||||
|
||||
const knownKeys = encryptionManager.getEncryptionKeys();
|
||||
|
||||
// My own key should be there
|
||||
const myRing = knownKeys.get(getParticipantId("@alice:example.org", "DEVICE01"));
|
||||
expect(myRing).toBeDefined();
|
||||
expect(myRing).toHaveLength(1);
|
||||
expect(myRing![0]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 0,
|
||||
key: expect.any(Uint8Array),
|
||||
}),
|
||||
);
|
||||
|
||||
const carlRing = knownKeys.get(getParticipantId("@carl:example.org", "CARLDEVICE"));
|
||||
expect(carlRing).toBeDefined();
|
||||
expect(carlRing).toHaveLength(2);
|
||||
expect(carlRing![0]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 0,
|
||||
key: decodeBase64("BBBBBBBBBBB"),
|
||||
}),
|
||||
);
|
||||
expect(carlRing![1]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 5,
|
||||
key: decodeBase64("CCCCCCCCCCC"),
|
||||
}),
|
||||
);
|
||||
|
||||
const bobRing = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE2"));
|
||||
expect(bobRing).toBeDefined();
|
||||
expect(bobRing).toHaveLength(1);
|
||||
expect(bobRing![0]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 0,
|
||||
key: decodeBase64("DDDDDDDDDDD"),
|
||||
}),
|
||||
);
|
||||
|
||||
const bob1Ring = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE"));
|
||||
expect(bob1Ring).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("Should only rotate once again if several membership changes during a rollout", async () => {
|
||||
@@ -442,7 +716,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The initial rollout
|
||||
@@ -458,7 +732,7 @@ describe("RTCEncryptionManager", () => {
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// This should start a new key rollout
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now simulate a new leaver
|
||||
@@ -466,14 +740,14 @@ describe("RTCEncryptionManager", () => {
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// The key `1` rollout is in progress
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// And another one ( plus a joiner)
|
||||
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
|
||||
getMembershipMock.mockReturnValue(lastMembership);
|
||||
// The key `1` rollout is still in progress
|
||||
encryptionManager.onMembershipsUpdate(lastMembership);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Let all rollouts finish
|
||||
@@ -558,7 +832,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
@@ -577,7 +851,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
// The key should have beed re-distributed to the room transport
|
||||
// The key should have been re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -590,9 +864,33 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
"!room:id",
|
||||
userId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function expectKeyAtIndexToHaveBeenSentTo(
|
||||
mockTransport: Mocked<ToDeviceKeyTransport>,
|
||||
index: number,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) {
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
index,
|
||||
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
|
||||
);
|
||||
}
|
||||
function expectKeyAtIndexNotToHaveBeenSentTo(
|
||||
mockTransport: Mocked<ToDeviceKeyTransport>,
|
||||
index: number,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) {
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
index,
|
||||
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,12 @@ import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent }
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
|
||||
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
|
||||
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
|
||||
|
||||
export const membershipTemplate: SessionMembershipData = {
|
||||
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
user_id: "@mock:user.example",
|
||||
device_id: "AAAAAAA",
|
||||
scope: "m.room",
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
@@ -68,7 +69,7 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
}
|
||||
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData,
|
||||
membershipData: MembershipData[],
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
@@ -87,10 +88,8 @@ export function makeMockRoom(
|
||||
});
|
||||
}
|
||||
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
|
||||
const events = Array.isArray(membershipData)
|
||||
? membershipData.map((m) => mockRTCEvent(m, roomId))
|
||||
: [mockRTCEvent(membershipData, roomId)];
|
||||
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
|
||||
const events = membershipData.map((m) => mockRTCEvent(m, roomId));
|
||||
const keysAndEvents = events.map((e) => {
|
||||
const data = e.getContent() as SessionMembershipData;
|
||||
return [`_${e.sender?.userId}_${data.device_id}`];
|
||||
@@ -120,6 +119,10 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRoomState(room: Room, membershipData: MembershipData[]): void {
|
||||
room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId));
|
||||
}
|
||||
|
||||
export function makeMockEvent(
|
||||
type: string,
|
||||
sender: string,
|
||||
@@ -138,13 +141,12 @@ export function makeMockEvent(
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData);
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
|
||||
@@ -164,6 +164,23 @@ describe("oidc authorization", () => {
|
||||
|
||||
expect(authUrl.searchParams.get("prompt")).toEqual("create");
|
||||
});
|
||||
|
||||
it("should generate url with login_hint", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata: delegatedAuthConfig,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
loginHint: "login1234",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
|
||||
@@ -126,13 +126,15 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
// flush the 500 response
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// flush the 200 response
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
@@ -150,13 +152,15 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
// flush the 500 response
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
// flush the 200 response
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
|
||||
+20
-146
@@ -20,8 +20,8 @@ import * as utils from "../test-utils/test-utils";
|
||||
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
|
||||
import {
|
||||
createClient,
|
||||
EventType,
|
||||
type MatrixClient,
|
||||
MatrixEvent,
|
||||
type RoomState,
|
||||
UNSTABLE_MSC2666_MUTUAL_ROOMS,
|
||||
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
|
||||
@@ -101,158 +101,32 @@ describe("RoomMember", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPowerLevelEvent", function () {
|
||||
it("should set 'powerLevel' and 'powerLevelNorm'.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@bertha:bar": 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
member.setPowerLevelEvent(event);
|
||||
expect(member.powerLevel).toEqual(20);
|
||||
expect(member.powerLevelNorm).toEqual(10);
|
||||
|
||||
const memberB = new RoomMember(roomId, userB);
|
||||
memberB.setPowerLevelEvent(event);
|
||||
expect(memberB.powerLevel).toEqual(200);
|
||||
expect(memberB.powerLevelNorm).toEqual(100);
|
||||
});
|
||||
|
||||
it("should emit 'RoomMember.powerLevel' if the power level changes.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@bertha:bar": 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember).toEqual(member);
|
||||
expect(emitEvent).toEqual(event);
|
||||
});
|
||||
|
||||
member.setPowerLevelEvent(event);
|
||||
expect(emitCount).toEqual(1);
|
||||
member.setPowerLevelEvent(event); // no-op
|
||||
expect(emitCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should honour power levels of zero.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": 0,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
// set the power level to something other than zero or we
|
||||
// won't get an event
|
||||
member.powerLevel = 1;
|
||||
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(0);
|
||||
expect(emitEvent).toEqual(event);
|
||||
});
|
||||
|
||||
member.setPowerLevelEvent(event);
|
||||
describe("setPowerLevel", function () {
|
||||
it("should set 'powerLevel'.", function () {
|
||||
member.setPowerLevel(0, new MatrixEvent());
|
||||
expect(member.powerLevel).toEqual(0);
|
||||
expect(emitCount).toEqual(1);
|
||||
member.setPowerLevel(200, new MatrixEvent());
|
||||
expect(member.powerLevel).toEqual(200);
|
||||
});
|
||||
|
||||
it("should not honor string power levels.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
it("should emit when power level set", function () {
|
||||
const onEmit = jest.fn();
|
||||
member.on(RoomMemberEvent.PowerLevel, onEmit);
|
||||
|
||||
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(20);
|
||||
expect(emitEvent).toEqual(event);
|
||||
});
|
||||
const aMatrixEvent = new MatrixEvent();
|
||||
member.setPowerLevel(10, aMatrixEvent);
|
||||
|
||||
member.setPowerLevelEvent(event);
|
||||
expect(member.powerLevel).toEqual(20);
|
||||
expect(emitCount).toEqual(1);
|
||||
expect(onEmit).toHaveBeenCalledWith(aMatrixEvent, member);
|
||||
});
|
||||
|
||||
it("should no-op if given a non-state or unrelated event", () => {
|
||||
const fn = jest.spyOn(member, "emit");
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
member.setPowerLevelEvent(
|
||||
utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
skey: "invalid",
|
||||
event: true,
|
||||
}),
|
||||
);
|
||||
const nonStateEv = utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
delete nonStateEv.event.state_key;
|
||||
member.setPowerLevelEvent(nonStateEv);
|
||||
member.setPowerLevelEvent(
|
||||
utils.mkEvent({
|
||||
type: EventType.Sticker,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {},
|
||||
event: true,
|
||||
}),
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
it("should not emit if new power level is the same", function () {
|
||||
const onEmit = jest.fn();
|
||||
member.on(RoomMemberEvent.PowerLevel, onEmit);
|
||||
|
||||
const aMatrixEvent = new MatrixEvent();
|
||||
member.setPowerLevel(0, aMatrixEvent);
|
||||
|
||||
expect(onEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as utils from "../test-utils/test-utils";
|
||||
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
|
||||
import { filterEmitCallsByEventType } from "../test-utils/emitter";
|
||||
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
|
||||
import { RoomMemberEvent } from "../../src/models/room-member";
|
||||
import { type Beacon, BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
|
||||
import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
@@ -38,10 +39,10 @@ describe("RoomState", function () {
|
||||
const userLazy = "@lazy:bar";
|
||||
|
||||
let state = new RoomState(roomId);
|
||||
let statev12 = new RoomState(roomId);
|
||||
|
||||
beforeEach(function () {
|
||||
state = new RoomState(roomId);
|
||||
state.setStateEvents([
|
||||
const commonEvents = [
|
||||
utils.mkMembership({
|
||||
// userA joined
|
||||
event: true,
|
||||
@@ -66,6 +67,11 @@ describe("RoomState", function () {
|
||||
name: "Room name goes here",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
state = new RoomState(roomId);
|
||||
state.setStateEvents([
|
||||
...commonEvents,
|
||||
utils.mkEvent({
|
||||
// Room creation
|
||||
type: "m.room.create",
|
||||
@@ -75,6 +81,19 @@ describe("RoomState", function () {
|
||||
content: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
statev12 = new RoomState(roomId);
|
||||
statev12.setStateEvents([
|
||||
...commonEvents,
|
||||
utils.mkEvent({
|
||||
// Room creation (v12 version)
|
||||
type: "m.room.create",
|
||||
user: userA,
|
||||
room: roomId,
|
||||
event: true,
|
||||
content: { room_version: "12" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("getMembers", function () {
|
||||
@@ -259,7 +278,7 @@ describe("RoomState", function () {
|
||||
expect(emitCount).toEqual(2);
|
||||
});
|
||||
|
||||
it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function () {
|
||||
it("should call setPowerLevel on each RoomMember for m.room.power_levels", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
@@ -273,12 +292,12 @@ describe("RoomState", function () {
|
||||
});
|
||||
|
||||
// spy on the room members
|
||||
jest.spyOn(state.members[userA], "setPowerLevelEvent");
|
||||
jest.spyOn(state.members[userB], "setPowerLevelEvent");
|
||||
jest.spyOn(state.members[userA], "setPowerLevel");
|
||||
jest.spyOn(state.members[userB], "setPowerLevel");
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
|
||||
expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
|
||||
expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
|
||||
expect(state.members[userA].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent);
|
||||
expect(state.members[userB].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent);
|
||||
});
|
||||
|
||||
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function () {
|
||||
@@ -310,6 +329,156 @@ describe("RoomState", function () {
|
||||
expect(state.members[userC].powerLevel).toEqual(10);
|
||||
});
|
||||
|
||||
it("should calculate power level correctly", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
[userB]: 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
|
||||
expect(state.getMember(userA)?.powerLevel).toEqual(20);
|
||||
expect(state.getMember(userB)?.powerLevel).toEqual(200);
|
||||
});
|
||||
|
||||
it("should set 'powerLevel' with a v12 room.", function () {
|
||||
const createEventV12 = utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
sender: userA,
|
||||
content: { room_version: "12" },
|
||||
event: true,
|
||||
});
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
[userB]: 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
state.setStateEvents([createEventV12, powerLevelEvent]);
|
||||
expect(state.getMember(userA)?.powerLevel).toEqual(Infinity);
|
||||
});
|
||||
|
||||
it("should honour power levels of zero.", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": 0,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
const memberA = state.getMember(userA)!;
|
||||
// set the power level to something other than zero or we
|
||||
// won't get an event
|
||||
memberA.powerLevel = 1;
|
||||
memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(0);
|
||||
expect(emitEvent).toEqual(powerLevelEvent);
|
||||
});
|
||||
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
expect(memberA.powerLevel).toEqual(0);
|
||||
expect(emitCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should not honor string power levels.", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
const memberA = state.getMember(userA)!;
|
||||
memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(20);
|
||||
expect(emitEvent).toEqual(powerLevelEvent);
|
||||
});
|
||||
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
expect(memberA.powerLevel).toEqual(20);
|
||||
expect(emitCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should no-op if given a non-state or unrelated event", () => {
|
||||
const memberA = state.getMember(userA)!;
|
||||
const fn = jest.spyOn(memberA, "emit");
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
skey: "invalid",
|
||||
event: true,
|
||||
});
|
||||
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
const nonStateEv = utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
delete nonStateEv.event.state_key;
|
||||
state.setStateEvents([nonStateEv]);
|
||||
state.setStateEvents([
|
||||
utils.mkEvent({
|
||||
type: EventType.Sticker,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {},
|
||||
event: true,
|
||||
}),
|
||||
]);
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
});
|
||||
|
||||
it("should call setMembershipEvent on the right RoomMember", function () {
|
||||
const memberEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
@@ -851,6 +1020,24 @@ describe("RoomState", function () {
|
||||
expect(state.maySendEvent("m.room.other_thing", userA)).toEqual(true);
|
||||
expect(state.maySendEvent("m.room.other_thing", userB)).toEqual(false);
|
||||
});
|
||||
|
||||
it("should recognise power level of room creators in v12 rooms", function () {
|
||||
const powerLevelEvent = new MatrixEvent({
|
||||
type: "m.room.power_levels",
|
||||
room_id: roomId,
|
||||
sender: userA,
|
||||
state_key: "",
|
||||
content: {
|
||||
users_default: 0,
|
||||
state_default: 100,
|
||||
events_default: 100,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
statev12.setStateEvents([powerLevelEvent]);
|
||||
|
||||
expect(statev12.maySendEvent("m.room.name", userA)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processBeaconEvents", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from "debug";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import {
|
||||
BaseMigrationData,
|
||||
@@ -31,6 +32,7 @@ import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import {
|
||||
type AccountDataEvents,
|
||||
DebugLogger,
|
||||
type Device,
|
||||
DeviceVerification,
|
||||
type EmptyObject,
|
||||
@@ -45,7 +47,7 @@ import {
|
||||
MemoryCryptoStore,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
import { emitPromise, mkEvent } from "../../test-utils/test-utils";
|
||||
import { emitPromise, mkEvent, waitFor } from "../../test-utils/test-utils";
|
||||
import { type CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { type IEventDecryptionResult, type IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
@@ -68,7 +70,6 @@ import {
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { ClientEvent, type ClientEventHandlerMap } from "../../../src/client";
|
||||
import { type Curve25519AuthData } from "../../../src/crypto-api/keybackup";
|
||||
@@ -117,6 +118,7 @@ describe("initRustCrypto", () => {
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
@@ -128,8 +130,8 @@ describe("initRustCrypto", () => {
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase");
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase", logger);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
|
||||
});
|
||||
|
||||
it("passes through the store params (key)", async () => {
|
||||
@@ -140,6 +142,7 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const storeKey = new Uint8Array(32);
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
@@ -151,8 +154,8 @@ describe("initRustCrypto", () => {
|
||||
storeKey: storeKey,
|
||||
});
|
||||
|
||||
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey, logger);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
|
||||
});
|
||||
|
||||
it("suppresses the storePassphrase and storeKey if storePrefix is unset", async () => {
|
||||
@@ -162,6 +165,7 @@ describe("initRustCrypto", () => {
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
@@ -174,8 +178,8 @@ describe("initRustCrypto", () => {
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith();
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith(null, null, logger);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
|
||||
});
|
||||
|
||||
it("Should get secrets from inbox on start", async () => {
|
||||
@@ -186,7 +190,7 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
logger: new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto")),
|
||||
http: {} as MatrixClient["http"],
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
@@ -251,9 +255,10 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
// console.log(`migrated ${progress} of ${total}`);
|
||||
}
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: makeMatrixHttpApi(),
|
||||
@@ -273,6 +278,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(BaseMigrationData),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
|
||||
expect(data.pickledAccount).toEqual("not a real account");
|
||||
@@ -289,6 +295,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
// First call should have 50 entries; second should have 10
|
||||
const sessions1: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[0][0];
|
||||
@@ -311,6 +318,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
// First call should have 50 entries; second should have 10
|
||||
const megolmSessions1: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
|
||||
@@ -358,11 +366,11 @@ describe("initRustCrypto", () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
// console.log(`migrated ${progress} of ${total}`);
|
||||
}
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
logger: new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto")),
|
||||
http: makeMatrixHttpApi(),
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
@@ -400,6 +408,7 @@ describe("initRustCrypto", () => {
|
||||
);
|
||||
|
||||
const PICKLE_KEY = "pickle1234";
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: makeMatrixHttpApi(),
|
||||
@@ -418,6 +427,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
const megolmSessions: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
|
||||
.calls[0][0];
|
||||
@@ -813,7 +823,7 @@ describe("RustCrypto", () => {
|
||||
} as unknown as OlmMachine;
|
||||
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
mockOlmMachine,
|
||||
fetchMock as unknown as MatrixHttpApi<any>,
|
||||
TEST_USER,
|
||||
@@ -898,6 +908,7 @@ describe("RustCrypto", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:RustCrypto"));
|
||||
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
|
||||
|
||||
rustCrypto = new RustCrypto(
|
||||
@@ -957,7 +968,11 @@ describe("RustCrypto", () => {
|
||||
|
||||
const outgoingRequestProcessor = {} as unknown as OutgoingRequestProcessor;
|
||||
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
|
||||
const outgoingRequestsManager = new OutgoingRequestsManager(
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
outgoingRequestProcessor,
|
||||
);
|
||||
rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager;
|
||||
|
||||
// The second time we do a /keys/upload, the `device_keys` property
|
||||
@@ -1015,7 +1030,7 @@ describe("RustCrypto", () => {
|
||||
getRoomEventEncryptionInfo: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1102,6 +1117,7 @@ describe("RustCrypto", () => {
|
||||
|
||||
it.each([
|
||||
[undefined, undefined, null],
|
||||
["Other", -1, EventShieldReason.UNKNOWN],
|
||||
[
|
||||
"Encrypted by an unverified user.",
|
||||
RustSdkCryptoJs.ShieldStateCode.UnverifiedIdentity,
|
||||
@@ -1128,6 +1144,11 @@ describe("RustCrypto", () => {
|
||||
RustSdkCryptoJs.ShieldStateCode.VerificationViolation,
|
||||
EventShieldReason.VERIFICATION_VIOLATION,
|
||||
],
|
||||
[
|
||||
"Mismatched sender",
|
||||
RustSdkCryptoJs.ShieldStateCode.MismatchedSender,
|
||||
EventShieldReason.MISMATCHED_SENDER,
|
||||
],
|
||||
])("gets the right shield reason (%s)", async (rustReason, rustCode, expectedReason) => {
|
||||
// suppress the warning from the unknown shield reason
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
@@ -1228,7 +1249,7 @@ describe("RustCrypto", () => {
|
||||
getDevice: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1471,7 +1492,7 @@ describe("RustCrypto", () => {
|
||||
getIdentity: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1537,14 +1558,6 @@ describe("RustCrypto", () => {
|
||||
const e2eKeyReceiver = new E2EKeyReceiver("http://server");
|
||||
const e2eKeyResponder = new E2EKeyResponder("http://server");
|
||||
e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver);
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
await expect(rustCrypto.pinCurrentUserIdentity(TEST_USER)).rejects.toThrow(
|
||||
"Cannot pin identity of own user",
|
||||
@@ -1558,7 +1571,7 @@ describe("RustCrypto", () => {
|
||||
getIdentity: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1630,7 +1643,7 @@ describe("RustCrypto", () => {
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
makeMatrixHttpApi(),
|
||||
testData.TEST_USER_ID,
|
||||
@@ -1782,14 +1795,6 @@ describe("RustCrypto", () => {
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
||||
|
||||
// dehydration requires secret storage and cross signing
|
||||
@@ -1923,14 +1928,6 @@ describe("RustCrypto", () => {
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
||||
|
||||
// dehydration requires secret storage and cross signing
|
||||
@@ -2291,8 +2288,9 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
|
||||
|
||||
// We have a key backup
|
||||
expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull();
|
||||
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
|
||||
|
||||
const authUploadDeviceSigningKeys = jest.fn();
|
||||
await rustCrypto.resetEncryption(authUploadDeviceSigningKeys);
|
||||
@@ -2348,6 +2346,76 @@ describe("RustCrypto", () => {
|
||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeAcceptKeyBundle", () => {
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockOlmMachine = {
|
||||
getReceivedRoomKeyBundleData: jest.fn(),
|
||||
receiveRoomKeyBundle: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
|
||||
const http = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
rustCrypto = new RustCrypto(
|
||||
new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec:maybeAcceptKeyBundle")),
|
||||
mockOlmMachine,
|
||||
http,
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if there is no key bundle", async () => {
|
||||
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(undefined);
|
||||
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
|
||||
expect(mockOlmMachine.getReceivedRoomKeyBundleData).toHaveBeenCalledTimes(1);
|
||||
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][0].toString()).toEqual("!room_id");
|
||||
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][1].toString()).toEqual("@bob:example.org");
|
||||
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the bundle via http and throws an error on failure", async () => {
|
||||
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
|
||||
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
|
||||
|
||||
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(() => rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org")).rejects.toMatchObject({
|
||||
errcode: "M_NOT_FOUND",
|
||||
httpStatus: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the bundle via http and passes it back into the OlmMachine", async () => {
|
||||
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
|
||||
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
|
||||
|
||||
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
|
||||
body: "asdfghjkl",
|
||||
});
|
||||
|
||||
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
@@ -2371,7 +2439,7 @@ async function makeTestRustCrypto(
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto({
|
||||
logger,
|
||||
logger: new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec")),
|
||||
http,
|
||||
userId,
|
||||
deviceId,
|
||||
|
||||
+8
-1
@@ -51,7 +51,11 @@ import {
|
||||
type SDPStreamMetadata,
|
||||
type SDPStreamMetadataKey,
|
||||
} from "../webrtc/callEventTypes.ts";
|
||||
import { type EncryptionKeysEventContent, type ICallNotifyContent } from "../matrixrtc/types.ts";
|
||||
import {
|
||||
type IRTCNotificationContent,
|
||||
type EncryptionKeysEventContent,
|
||||
type ICallNotifyContent,
|
||||
} from "../matrixrtc/types.ts";
|
||||
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
||||
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
||||
@@ -147,6 +151,7 @@ export enum EventType {
|
||||
|
||||
// MatrixRTC events
|
||||
CallNotify = "org.matrix.msc4075.call.notify",
|
||||
RTCNotification = "org.matrix.msc4075.rtc.notification",
|
||||
}
|
||||
|
||||
export enum RelationType {
|
||||
@@ -158,6 +163,7 @@ export enum RelationType {
|
||||
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
|
||||
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
|
||||
Thread = "m.thread",
|
||||
unstable_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent",
|
||||
}
|
||||
|
||||
export enum MsgType {
|
||||
@@ -325,6 +331,7 @@ export interface TimelineEvents {
|
||||
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
|
||||
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
|
||||
[EventType.CallNotify]: ICallNotifyContent;
|
||||
[EventType.RTCNotification]: IRTCNotificationContent;
|
||||
[M_BEACON.name]: MBeaconEventContent;
|
||||
[M_POLL_START.name]: PollStartEventContent;
|
||||
[M_POLL_END.name]: PollEndEventContent;
|
||||
|
||||
+26
-3
@@ -28,9 +28,7 @@ import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
||||
|
||||
export interface IJoinRoomOpts {
|
||||
/**
|
||||
* True to do a room initial sync on the resulting
|
||||
* room. If false, the <strong>returned Room object will have no current state.
|
||||
* </strong> Default: true.
|
||||
* @deprecated does nothing
|
||||
*/
|
||||
syncRoom?: boolean;
|
||||
|
||||
@@ -43,6 +41,31 @@ export interface IJoinRoomOpts {
|
||||
* The server names to try and join through in addition to those that are automatically chosen.
|
||||
*/
|
||||
viaServers?: string[];
|
||||
|
||||
/**
|
||||
* When accepting an invite, whether to accept encrypted history shared by the inviter via the experimental
|
||||
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
acceptSharedHistory?: boolean;
|
||||
}
|
||||
|
||||
/** Options object for {@link MatrixClient.invite}. */
|
||||
export interface InviteOpts {
|
||||
/**
|
||||
* The reason for the invite.
|
||||
*/
|
||||
reason?: string;
|
||||
|
||||
/**
|
||||
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
|
||||
* visibility was `shared`, via the experimental
|
||||
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
shareEncryptedHistory?: boolean;
|
||||
}
|
||||
|
||||
export interface KnockRoomOpts {
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk/lib/types";
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
/**
|
||||
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
|
||||
|
||||
+38
-20
@@ -115,6 +115,7 @@ import {
|
||||
type IGuestAccessOpts,
|
||||
type IJoinRoomOpts,
|
||||
type INotificationsResponse,
|
||||
type InviteOpts,
|
||||
type IPaginateOpts,
|
||||
type IPresenceOpts,
|
||||
type IRedactOpts,
|
||||
@@ -222,9 +223,9 @@ import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts";
|
||||
import {
|
||||
type CrossSigningKeyInfo,
|
||||
type CryptoApi,
|
||||
type CryptoCallbacks,
|
||||
CryptoEvent,
|
||||
type CryptoEventHandlerMap,
|
||||
type CryptoCallbacks,
|
||||
} from "./crypto-api/index.ts";
|
||||
import {
|
||||
type SecretStorageKeyDescription,
|
||||
@@ -439,13 +440,13 @@ export interface ICreateClientOpts {
|
||||
|
||||
/**
|
||||
* If true, group calls will not establish media connectivity and only create the signaling events,
|
||||
* so that livekit media can be used in the application layert (js-sdk contains no livekit code).
|
||||
* so that livekit media can be used in the application layer (js-sdk contains no livekit code).
|
||||
*/
|
||||
useLivekitForGroupCalls?: boolean;
|
||||
|
||||
/**
|
||||
* A logger to associate with this MatrixClient.
|
||||
* Defaults to the built-in global logger.
|
||||
* Defaults to the built-in global logger; see {@link DebugLogger} for an alternative.
|
||||
*/
|
||||
logger?: Logger;
|
||||
}
|
||||
@@ -2209,7 +2210,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
): Promise<EmptyObject> {
|
||||
// If the sync loop is not running, fall back to setAccountDataRaw.
|
||||
if (!this.clientRunning) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Calling `setAccountData` before the client is started: `getAccountData` may return inconsistent results.",
|
||||
);
|
||||
return await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
|
||||
@@ -2369,12 +2370,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
|
||||
if (opts.syncRoom === undefined) {
|
||||
opts.syncRoom = true;
|
||||
}
|
||||
|
||||
const room = this.getRoom(roomIdOrAlias);
|
||||
if (room?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return room;
|
||||
const roomMember = room?.getMember(this.getSafeUserId());
|
||||
const preJoinMembership = roomMember?.membership;
|
||||
|
||||
// If we were invited to the room, the ID of the user that sent the invite. Otherwise, `null`.
|
||||
const inviter =
|
||||
preJoinMembership == KnownMembership.Invite ? (roomMember?.events.member?.getSender() ?? null) : null;
|
||||
|
||||
this.logger.debug(
|
||||
`joinRoom[${roomIdOrAlias}]: preJoinMembership=${preJoinMembership}, inviter=${inviter}, opts=${JSON.stringify(opts)}`,
|
||||
);
|
||||
if (preJoinMembership == KnownMembership.Join) return room!;
|
||||
|
||||
let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
|
||||
|
||||
@@ -2401,6 +2408,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
|
||||
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
|
||||
}
|
||||
|
||||
// In case we were originally given an alias, check the room cache again
|
||||
// with the resolved ID - this method is supposed to no-op if we already
|
||||
// were in the room, after all.
|
||||
@@ -2408,12 +2419,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (resolvedRoom?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return resolvedRoom;
|
||||
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const syncRoom = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
// return syncApi.syncRoom(room);
|
||||
}
|
||||
return syncRoom;
|
||||
return syncApi.createRoom(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3764,12 +3770,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param reason - Optional.
|
||||
* @returns Promise which resolves: `{}` an empty object.
|
||||
* @returns Rejects: with an error response.
|
||||
* Send an invite to the given user to join the given room.
|
||||
*
|
||||
* @param roomId - The ID of the room to which the user should be invited.
|
||||
* @param userId - The ID of the user that should be invited.
|
||||
* @param opts - Optional reason object. For backwards compatibility, a string is also accepted, and will be interpreted as a reason.
|
||||
*
|
||||
* @returns An empty object.
|
||||
*/
|
||||
public invite(roomId: string, userId: string, reason?: string): Promise<EmptyObject> {
|
||||
return this.membershipChange(roomId, userId, KnownMembership.Invite, reason);
|
||||
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
|
||||
if (typeof opts != "object") {
|
||||
opts = { reason: opts };
|
||||
}
|
||||
|
||||
if (opts.shareEncryptedHistory) {
|
||||
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
|
||||
}
|
||||
|
||||
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,6 +79,19 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @returns a promise which resolves once the keys have been imported
|
||||
*/
|
||||
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Having accepted an invite for the given room from the given user, attempt to
|
||||
* find information about a room key bundle and, if found, download the
|
||||
* bundle and import the room keys, as per {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268}.
|
||||
*
|
||||
* @param roomId - The room we were invited to, for which we want to check if a room
|
||||
* key bundle was received.
|
||||
*
|
||||
* @param inviter - The user who invited us to the room and is expected to have
|
||||
* sent the room key bundle.
|
||||
*/
|
||||
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
|
||||
@@ -729,6 +729,20 @@ export interface CryptoApi {
|
||||
* @param secrets - The secrets bundle received from the other device
|
||||
*/
|
||||
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Room key history sharing (MSC4268)
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Share any shareable E2EE history in the given room with the given recipient,
|
||||
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
@@ -1355,6 +1369,12 @@ export enum EventShieldReason {
|
||||
* The sender was previously verified but changed their identity.
|
||||
*/
|
||||
VERIFICATION_VIOLATION,
|
||||
|
||||
/**
|
||||
* The `sender` field on the event does not match the owner of the device
|
||||
* that established the Megolm session.
|
||||
*/
|
||||
MISMATCHED_SENDER,
|
||||
}
|
||||
|
||||
/** The result of a call to {@link CryptoApi.getOwnDeviceKeys} */
|
||||
|
||||
+35
-18
@@ -23,6 +23,7 @@ import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||
import {
|
||||
type BaseRequestOpts,
|
||||
HttpApiEvent,
|
||||
type HttpApiEventHandlerMap,
|
||||
type IHttpOpts,
|
||||
@@ -37,11 +38,17 @@ interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
}
|
||||
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
|
||||
? string
|
||||
: O extends { onlyData: true } | undefined
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
/**
|
||||
* The type returned by {@link FetchHttpApi.request}, etc.
|
||||
*
|
||||
* If {@link IHttpOpts.onlyData} is unset or false, then the request methods return a
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object,
|
||||
* which we abstract via `TypedResponse`. Otherwise, we just cast it to `T`.
|
||||
*
|
||||
* @typeParam T - The type (specified by the application on the request method) that we will cast the response to.
|
||||
* @typeParam O - The type of the options object on the {@link FetchHttpApi} instance.
|
||||
*/
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends { onlyData: true } | undefined ? T : TypedResponse<T>;
|
||||
|
||||
export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private abortController = new AbortController();
|
||||
@@ -263,21 +270,20 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
method: Method,
|
||||
url: URL | string,
|
||||
body?: Body,
|
||||
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
|
||||
opts: BaseRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
|
||||
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
|
||||
}
|
||||
|
||||
const urlForLogs = this.sanitizeUrlForLogs(url);
|
||||
|
||||
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
|
||||
|
||||
const headers = Object.assign({}, opts.headers || {});
|
||||
const json = opts.json ?? true;
|
||||
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
|
||||
const jsonBody = json && body?.constructor?.name === Object.name;
|
||||
|
||||
if (json) {
|
||||
if (jsonBody && !headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const jsonResponse = !opts.rawResponseBody && opts.json !== false;
|
||||
if (jsonResponse) {
|
||||
if (!headers["Accept"]) {
|
||||
headers["Accept"] = "application/json";
|
||||
}
|
||||
@@ -293,9 +299,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
signals.push(opts.abortSignal);
|
||||
}
|
||||
|
||||
// If the body is an object, encode it as JSON and set the `Content-Type` header,
|
||||
// unless that has been explicitly inhibited by setting `opts.json: false`.
|
||||
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
|
||||
let data: BodyInit;
|
||||
if (jsonBody) {
|
||||
if (opts.json !== false && body?.constructor?.name === Object.name) {
|
||||
data = JSON.stringify(body);
|
||||
if (!headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
} else {
|
||||
data = body as BodyInit;
|
||||
}
|
||||
@@ -337,10 +349,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
|
||||
if (this.opts.onlyData) {
|
||||
return (json ? res.json() : res.text()) as ResponseType<T, O>;
|
||||
if (!this.opts.onlyData) {
|
||||
return res as ResponseType<T, O>;
|
||||
} else if (opts.rawResponseBody) {
|
||||
return (await res.blob()) as ResponseType<T, O>;
|
||||
} else if (jsonResponse) {
|
||||
return await res.json();
|
||||
} else {
|
||||
return (await res.text()) as ResponseType<T, O>;
|
||||
}
|
||||
return res as ResponseType<T, O>;
|
||||
}
|
||||
|
||||
private sanitizeUrlForLogs(url: URL | string): string {
|
||||
|
||||
+51
-12
@@ -47,6 +47,8 @@ export type AccessTokens = {
|
||||
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
|
||||
*/
|
||||
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
|
||||
|
||||
/** Options object for `FetchHttpApi` and {@link MatrixHttpApi}. */
|
||||
export interface IHttpOpts {
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
|
||||
@@ -67,24 +69,20 @@ export interface IHttpOpts {
|
||||
tokenRefreshFunction?: TokenRefreshFunction;
|
||||
useAuthorizationHeader?: boolean; // defaults to true
|
||||
|
||||
/**
|
||||
* Normally, methods in `FetchHttpApi` will return a {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object.
|
||||
* If this is set to `true`, they instead return the response body.
|
||||
*/
|
||||
onlyData?: boolean;
|
||||
|
||||
localTimeoutMs?: number;
|
||||
|
||||
/** Optional logger instance. If provided, requests and responses will be logged. */
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
|
||||
/**
|
||||
* The alternative base url to use.
|
||||
* If not specified, uses this.opts.baseUrl
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* The full prefix to use e.g.
|
||||
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
|
||||
*/
|
||||
prefix?: string;
|
||||
/** Options object for `FetchHttpApi.requestOtherUrl`. */
|
||||
export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
|
||||
/**
|
||||
* map of additional request headers
|
||||
*/
|
||||
@@ -96,7 +94,48 @@ export interface IRequestOpts extends Pick<RequestInit, "priority"> {
|
||||
*/
|
||||
localTimeoutMs?: number;
|
||||
keepAlive?: boolean; // defaults to false
|
||||
json?: boolean; // defaults to true
|
||||
|
||||
/**
|
||||
* By default, we will:
|
||||
*
|
||||
* * If the `body` is an object, JSON-encode it and set `Content-Type: application/json` in the
|
||||
* request headers (unless overridden by {@link headers}).
|
||||
*
|
||||
* * Set `Accept: application/json` in the request headers (again, unless overridden by {@link headers}).
|
||||
*
|
||||
* * If `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, parse the response as
|
||||
* JSON and return the parsed response.
|
||||
*
|
||||
* Setting this to `false` inhibits all three behaviors, and (if `IHTTPOpts.onlyData` is set to `true`) the response
|
||||
* is instead parsed as a UTF-8 string. It defaults to `true`, unless {@link rawResponseBody} is set.
|
||||
*
|
||||
* @deprecated Instead of setting this to `false`, set {@link rawResponseBody} to `true`.
|
||||
*/
|
||||
json?: boolean;
|
||||
|
||||
/**
|
||||
* Setting this to `true` does two things:
|
||||
*
|
||||
* * Inhibits the automatic addition of `Accept: application/json` in the request headers.
|
||||
*
|
||||
* * Assuming `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, causes the
|
||||
* raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
|
||||
* instead of parsing it as `json`.
|
||||
*/
|
||||
rawResponseBody?: boolean;
|
||||
}
|
||||
|
||||
export interface IRequestOpts extends BaseRequestOpts {
|
||||
/**
|
||||
* The alternative base url to use.
|
||||
* If not specified, uses this.opts.baseUrl
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* The full prefix to use e.g.
|
||||
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
|
||||
*/
|
||||
prefix?: string;
|
||||
|
||||
// Set to true to prevent the request function from emitting a Session.logged_out event.
|
||||
// This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response,
|
||||
|
||||
+72
-1
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
Copyright 2018 André Jaenisch
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019-2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -206,3 +206,74 @@ export class LogSpan implements BaseLogger {
|
||||
this.parent.error(this.name, ...msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplification of the `Debugger` type exposed by the `debug` library. We reimplement the bits we need here
|
||||
* to avoid a dependency on `debug`.
|
||||
*/
|
||||
interface Debugger {
|
||||
(formatter: any, ...args: any[]): void;
|
||||
extend: (namespace: string, delimiter?: string) => Debugger;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Logger` instance, suitable for use in {@link ICreateClientOpts.logger}, which will write to the `debug` library.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* import debug from "debug";
|
||||
*
|
||||
* const client = createClient({
|
||||
* baseUrl: homeserverUrl,
|
||||
* userId: userId,
|
||||
* accessToken: "akjgkrgjs",
|
||||
* deviceId: "xzcvb",
|
||||
* logger: new DebugLogger(debug(`matrix-js-sdk:${userId}`)),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class DebugLogger implements Logger {
|
||||
public constructor(private debugInstance: Debugger) {}
|
||||
|
||||
public trace(...msg: any[]): void {
|
||||
this.debugWithPrefix("[TRACE]", ...msg);
|
||||
}
|
||||
|
||||
public debug(...msg: any[]): void {
|
||||
this.debugWithPrefix("[DEBUG]", ...msg);
|
||||
}
|
||||
|
||||
public info(...msg: any[]): void {
|
||||
this.debugWithPrefix("[INFO]", ...msg);
|
||||
}
|
||||
|
||||
public warn(...msg: any[]): void {
|
||||
this.debugWithPrefix("[WARN]", ...msg);
|
||||
}
|
||||
|
||||
public error(...msg: any[]): void {
|
||||
this.debugWithPrefix("[ERROR]", ...msg);
|
||||
}
|
||||
|
||||
public getChild(namespace: string): DebugLogger {
|
||||
return new DebugLogger(this.debugInstance.extend(namespace));
|
||||
}
|
||||
|
||||
private debugWithPrefix(prefix: string, ...msg: any[]): void {
|
||||
let formatter: string;
|
||||
|
||||
// Convert the first argument to a string, so that we can safely add a prefix. This is much the same logic that
|
||||
// `debug()` uses.
|
||||
if (msg.length === 0) {
|
||||
formatter = "";
|
||||
} else if (msg[0] instanceof Error) {
|
||||
const err = msg.shift();
|
||||
formatter = err.stack || err.message;
|
||||
} else if (typeof msg[0] == "string") {
|
||||
formatter = msg.shift();
|
||||
} else {
|
||||
formatter = "%O";
|
||||
}
|
||||
this.debugInstance(prefix + " " + formatter, ...msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export { IdentityProviderBrand, SSOAction } from "./@types/auth.ts";
|
||||
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth.ts";
|
||||
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces.ts";
|
||||
export { LocationAssetType } from "./@types/location.ts";
|
||||
export { DebugLogger } from "./logger.ts";
|
||||
|
||||
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { type MatrixEvent } from "../matrix.ts";
|
||||
import { deepCompare } from "../utils.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
|
||||
/**
|
||||
* The default duration in milliseconds that a membership is considered valid for.
|
||||
@@ -130,6 +131,9 @@ export class CallMembership {
|
||||
return this.parentEvent.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use sessionDescription.id instead.
|
||||
*/
|
||||
public get callId(): string {
|
||||
return this.membershipData.call_id;
|
||||
}
|
||||
@@ -138,6 +142,13 @@ export class CallMembership {
|
||||
return this.membershipData.device_id;
|
||||
}
|
||||
|
||||
public get sessionDescription(): SessionDescription {
|
||||
return {
|
||||
application: this.membershipData.application,
|
||||
id: this.membershipData.call_id,
|
||||
};
|
||||
}
|
||||
|
||||
public get application(): string | undefined {
|
||||
return this.membershipData.application;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type Statistics } from "./types.ts";
|
||||
import { isMyMembership, type ParticipantId, type Statistics } from "./types.ts";
|
||||
import { getParticipantId } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
@@ -41,14 +41,9 @@ export interface IEncryptionManager {
|
||||
/**
|
||||
* Retrieves the encryption keys currently managed by the encryption manager.
|
||||
*
|
||||
* @returns A map where the keys are identifiers and the values are arrays of
|
||||
* objects containing encryption keys and their associated timestamps.
|
||||
* @deprecated This method is used internally for testing. It is also used to re-emit keys when there is a change
|
||||
* of RTCSession (matrixKeyProvider#setRTCSession) -Not clear why/when switch RTCSession would occur-. Note that if we switch focus, we do keep the same RTC session,
|
||||
* so no need to re-emit. But it requires the encryption manager to store all keys of all participants, and this is already done
|
||||
* by the key provider. We don't want to add another layer of key storage.
|
||||
* @returns A map of participant IDs to their encryption keys.
|
||||
*/
|
||||
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
|
||||
getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,8 +99,16 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
return this.encryptionKeys;
|
||||
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
|
||||
const keysMap = new Map<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>();
|
||||
for (const [userId, userKeys] of this.encryptionKeys) {
|
||||
const keys = userKeys.map((entry, index) => ({
|
||||
key: entry.key,
|
||||
keyIndex: index,
|
||||
}));
|
||||
keysMap.set(userId as ParticipantId, keys);
|
||||
}
|
||||
return keysMap;
|
||||
}
|
||||
|
||||
private joined = false;
|
||||
@@ -300,7 +303,6 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
|
||||
@@ -55,9 +55,13 @@ export interface IMembershipManager {
|
||||
* Get the actual connection status of the manager.
|
||||
*/
|
||||
get status(): Status;
|
||||
|
||||
/**
|
||||
* The current status while the manager is activated
|
||||
* The Current own state event if the manger is connected.
|
||||
* `undefined` if not connected.
|
||||
*/
|
||||
get ownMembership(): CallMembership | undefined;
|
||||
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
|
||||
@@ -19,15 +19,15 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { EventType, RelationType } from "../@types/event.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { MembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import { type Statistics } from "./types.ts";
|
||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||
import { type Statistics, type RTCNotificationType } from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { IMembershipManager } from "./IMembershipManager.ts";
|
||||
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
|
||||
@@ -65,19 +65,36 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export interface SessionConfig {
|
||||
/**
|
||||
* What kind of notification to send when starting the session.
|
||||
* @default `undefined` (no notification)
|
||||
*/
|
||||
notificationType?: RTCNotificationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session description is used to identify a session. Used in the state event.
|
||||
*/
|
||||
export interface SessionDescription {
|
||||
id: string;
|
||||
application: string;
|
||||
}
|
||||
|
||||
// The names follow these principles:
|
||||
// - we use the technical term delay if the option is related to delayed events.
|
||||
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
|
||||
// - we use membershipEvent if the option is related to the rtc member state event.
|
||||
// - we use the technical term expiry if the option is related to the expiry field of the membership state event.
|
||||
// - we use a `MS` postfix if the option is a duration to avoid using words like:
|
||||
// - we use a `Ms` postfix if the option is a duration to avoid using words like:
|
||||
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
* @deprecated does nothing anymore we always default to the new memberhip manager.
|
||||
* @deprecated does nothing anymore we always default to the new membership manager.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
@@ -134,6 +151,7 @@ export interface MembershipConfig {
|
||||
* failed to send due to a network error. (send membership event, send delayed event, restart delayed event...)
|
||||
*/
|
||||
networkErrorRetryMs?: number;
|
||||
|
||||
/** @deprecated renamed to `networkErrorRetryMs`*/
|
||||
callMemberEventRetryDelayMinimum?: number;
|
||||
|
||||
@@ -141,6 +159,17 @@ export interface MembershipConfig {
|
||||
* If true, use the new to-device transport for sending encryption keys.
|
||||
*/
|
||||
useExperimentalToDeviceTransport?: boolean;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
|
||||
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
|
||||
*
|
||||
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
|
||||
* helps by keeping more delayed event reset candidates in flight,
|
||||
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -153,22 +182,38 @@ export interface EncryptionConfig {
|
||||
/**
|
||||
* The minimum time (in milliseconds) between each attempt to send encryption key(s).
|
||||
* e.g. if this is set to 1000, then we will send at most one key event every second.
|
||||
* @deprecated - Not used by the new encryption manager.
|
||||
*/
|
||||
updateEncryptionKeyThrottle?: number;
|
||||
|
||||
/**
|
||||
* Sometimes it is necessary to rotate the encryption key after a membership update.
|
||||
* For performance reasons we might not want to rotate the key immediately but allow future memberships to use the same key.
|
||||
* If 5 people join in a row in less than 5 seconds, we don't want to rotate the key for each of them.
|
||||
* If 5 people leave in a row in less than 5 seconds, we don't want to rotate the key for each of them.
|
||||
* So we do share the key which was already used live for <5s to new joiners.
|
||||
* This does result in a potential leak up to the configured time of call media.
|
||||
* This has to be considered when choosing a value for this property.
|
||||
*/
|
||||
keyRotationGracePeriodMs?: number;
|
||||
|
||||
/**
|
||||
* The delay (in milliseconds) after a member leaves before we create and publish a new key, because people
|
||||
* tend to leave calls at the same time.
|
||||
* @deprecated - Not used by the new encryption manager.
|
||||
*/
|
||||
makeKeyDelay?: number;
|
||||
/**
|
||||
* The delay (in milliseconds) between creating and sending a new key and starting to encrypt with it. This
|
||||
* gives other a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
|
||||
* The total time between a member leaving and the call switching to new keys is therefore:
|
||||
* makeKeyDelay + useKeyDelay
|
||||
* The delay (in milliseconds) between sending a new key and starting to encrypt with it. This
|
||||
* gives others a chance to receive the new key to minimize the chance they get media they can't decrypt.
|
||||
*
|
||||
* The higher this value is, the better it is for existing members as they will have a smoother experience.
|
||||
* But it impacts new joiners: They will always have to wait `useKeyDelay` before being able to decrypt the media
|
||||
* (as it will be encrypted with the new key after the delay only), even if the key has already arrived before the delay.
|
||||
*/
|
||||
useKeyDelay?: number;
|
||||
}
|
||||
export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
|
||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
|
||||
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
@@ -182,7 +227,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
// The session Id of the call, this is the call_id of the call Member event.
|
||||
private _callId: string | undefined;
|
||||
private joinConfig?: SessionConfig;
|
||||
private logger: Logger;
|
||||
|
||||
private pendingNotificationToSend: undefined | RTCNotificationType;
|
||||
/**
|
||||
* This timeout is responsible to track any expiration. We need to know when we have to start
|
||||
* to ignore other call members. There is no callback for this. This timeout will always be configured to
|
||||
@@ -214,10 +262,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room, oldest first
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
|
||||
*/
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
): CallMembership[] {
|
||||
return MatrixRTCSession.sessionMembershipsForRoom(room, {
|
||||
id: "",
|
||||
application: "m.call",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*/
|
||||
public static sessionMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
sessionDescription: SessionDescription,
|
||||
): CallMembership[] {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
@@ -250,9 +315,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
|
||||
if (membership.callId !== "" || membership.scope !== "m.room") {
|
||||
// for now, just ignore anything that isn't a room scope call
|
||||
logger.info(`Ignoring user-scoped call`);
|
||||
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
|
||||
logger.info(
|
||||
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -283,12 +349,33 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MatrixRTC session for the room, whether there are currently active members or not
|
||||
* Return the MatrixRTC session for the room.
|
||||
* This returned session can be used to find out if there are active room call sessions
|
||||
* for the requested room.
|
||||
*
|
||||
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
|
||||
* sessionDescription `{ id: "", application: "m.call" }`.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
*/
|
||||
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.callMembershipsForRoom(room);
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
|
||||
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
||||
}
|
||||
|
||||
return new MatrixRTCSession(client, room, callMemberships);
|
||||
/**
|
||||
* Return the MatrixRTC session for the room.
|
||||
* This returned session can be used to find out if there are active sessions
|
||||
* for the requested room and `sessionDescription`.
|
||||
*/
|
||||
public static sessionForRoom(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
sessionDescription: SessionDescription,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
|
||||
|
||||
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,10 +420,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
>,
|
||||
public memberships: CallMembership[],
|
||||
/**
|
||||
* The session description is used to define the exact session this object is tracking.
|
||||
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
|
||||
*/
|
||||
public readonly sessionDescription: SessionDescription,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this._callId = memberships[0]?.callId;
|
||||
this._callId = memberships[0]?.sessionDescription.id;
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
@@ -394,6 +486,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.sessionDescription,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
@@ -447,6 +540,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
}
|
||||
|
||||
this.joinConfig = joinConfig;
|
||||
this.pendingNotificationToSend = this.joinConfig?.notificationType;
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
@@ -516,29 +612,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* the keys.
|
||||
*/
|
||||
public reemitEncryptionKeys(): void {
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
keys.forEach((key, index) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId);
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keyRing, participantId) => {
|
||||
keyRing.forEach((keyInfo) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyInfo.key, keyInfo.keyIndex, participantId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of keys used to encrypt and decrypt (we are using a symmetric
|
||||
* cipher) given participant's media. This also includes our own key
|
||||
*
|
||||
* @deprecated This will be made private in a future release.
|
||||
*/
|
||||
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
|
||||
const keys =
|
||||
this.encryptionManager?.getEncryptionKeys() ??
|
||||
new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
|
||||
// the returned array doesn't contain the timestamps
|
||||
return Array.from(keys.entries())
|
||||
.map(([participantId, keys]): [string, Uint8Array[]] => [participantId, keys.map((k) => k.key)])
|
||||
.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a timer for the soonest membership expiry
|
||||
*/
|
||||
@@ -563,6 +643,35 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification corresponding to the configured notify type.
|
||||
*/
|
||||
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
|
||||
// Send legacy event:
|
||||
this.client
|
||||
.sendEvent(this.roomSubset.roomId, EventType.CallNotify, {
|
||||
"application": "m.call",
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notify_type": notificationType === "notification" ? "notify" : notificationType,
|
||||
"call_id": this.callId!,
|
||||
})
|
||||
.catch((e) => this.logger.error("Failed to send call notification", e));
|
||||
|
||||
// Send new event:
|
||||
this.client
|
||||
.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, {
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": notificationType,
|
||||
"m.relates_to": {
|
||||
event_id: parentEventId,
|
||||
rel_type: RelationType.unstable_RTCNotificationParent,
|
||||
},
|
||||
"sender_ts": Date.now(),
|
||||
"lifetime": 30_000, // 30 seconds
|
||||
})
|
||||
.catch((e) => this.logger.error("Failed to send call notification", e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
@@ -586,9 +695,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*/
|
||||
private recalculateSessionMembers = (): void => {
|
||||
const oldMemberships = this.memberships;
|
||||
this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room);
|
||||
this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription);
|
||||
|
||||
this._callId = this._callId ?? this.memberships[0]?.callId;
|
||||
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
|
||||
|
||||
const changed =
|
||||
oldMemberships.length != this.memberships.length ||
|
||||
@@ -603,6 +712,20 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
});
|
||||
|
||||
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
|
||||
// The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`.
|
||||
const ownMembership = this.membershipManager?.ownMembership;
|
||||
if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) {
|
||||
// If we're the first member in the call, we're responsible for
|
||||
// sending the notification event
|
||||
if (ownMembership.eventId && this.joinConfig?.notificationType) {
|
||||
this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType);
|
||||
} else {
|
||||
this.logger.warn("Own membership eventId is undefined, cannot send call notification");
|
||||
}
|
||||
}
|
||||
// If anyone else joins the session it is no longer our responsibility to send the notification.
|
||||
// (If we were the joiner we already did sent the notification in the block above.)
|
||||
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
|
||||
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
@@ -37,6 +37,9 @@ type EventHandlerMap = {
|
||||
|
||||
/**
|
||||
* Holds all active MatrixRTC session objects and creates new ones as events arrive.
|
||||
* One `MatrixRTCSessionManager` is required for each MatrixRTC sessionDescription (application, session id) that the client wants to support.
|
||||
* If no application type is specified in the constructor, the default is "m.call".
|
||||
*
|
||||
* This interface is UNSTABLE and may change without warning.
|
||||
*/
|
||||
export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionManagerEvents, EventHandlerMap> {
|
||||
@@ -53,6 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
public constructor(
|
||||
rootLogger: Logger,
|
||||
private client: MatrixClient,
|
||||
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
@@ -62,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
|
||||
// returning nothing, and breaks tests if you change it to return an empty array :'(
|
||||
for (const room of this.client.getRooms() ?? []) {
|
||||
const session = MatrixRTCSession.roomSessionForRoom(this.client, room);
|
||||
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
|
||||
if (session.memberships.length > 0) {
|
||||
this.roomSessions.set(room.roomId, session);
|
||||
}
|
||||
@@ -96,7 +100,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
*/
|
||||
public getRoomSession(room: Room): MatrixRTCSession {
|
||||
if (!this.roomSessions.has(room.roomId)) {
|
||||
this.roomSessions.set(room.roomId, MatrixRTCSession.roomSessionForRoom(this.client, room));
|
||||
this.roomSessions.set(
|
||||
room.roomId,
|
||||
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
|
||||
);
|
||||
}
|
||||
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
|
||||
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { AbortError } from "p-retry";
|
||||
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
@@ -25,7 +26,7 @@ import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipDat
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isMyMembership, Status } from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { type SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import {
|
||||
@@ -85,19 +86,24 @@ export enum MembershipActionType {
|
||||
// -> MembershipActionType.SendJoinEvent if successful
|
||||
// -> DelayedLeaveActionType.SendDelayedEvent on error, retry sending the first delayed event.
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
|
||||
|
||||
SendJoinEvent = "SendJoinEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
|
||||
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
|
||||
|
||||
RestartDelayedEvent = "RestartDelayedEvent",
|
||||
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
|
||||
// -> DelayedLeaveActionType.SendDelayedEvent on missing delay id and there is no state event
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
|
||||
|
||||
UpdateExpiry = "UpdateExpiry",
|
||||
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
|
||||
|
||||
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
|
||||
// -> MembershipActionType.SendLeaveEvent on failure (not found) we need to send the leave manually and cannot use the scheduled delayed event
|
||||
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
|
||||
|
||||
SendLeaveEvent = "SendLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent
|
||||
}
|
||||
@@ -221,7 +227,13 @@ export class MembershipManager
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) {
|
||||
if (!userId || !deviceId) {
|
||||
this.logger.error("MembershipManager.onRTCSessionMemberUpdate called without user or device id");
|
||||
return Promise.resolve();
|
||||
}
|
||||
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
|
||||
|
||||
if (this.isActivated() && !this._ownMembership) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
@@ -231,7 +243,7 @@ export class MembershipManager
|
||||
this.logger.warn("Missing own membership: force re-join");
|
||||
this.state.hasMemberStateEvent = false;
|
||||
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
if (this.scheduler.actions.some((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
this.logger.error(
|
||||
"tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
@@ -282,6 +294,7 @@ export class MembershipManager
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
public readonly sessionDescription: SessionDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
@@ -310,6 +323,11 @@ export class MembershipManager
|
||||
}, this.logger);
|
||||
}
|
||||
|
||||
private _ownMembership?: CallMembership;
|
||||
public get ownMembership(): CallMembership | undefined {
|
||||
return this._ownMembership;
|
||||
}
|
||||
|
||||
// scheduler
|
||||
private oldStatus?: Status;
|
||||
private scheduler: ActionScheduler;
|
||||
@@ -374,6 +392,9 @@ export class MembershipManager
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||
}
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
@@ -525,8 +546,18 @@ export class MembershipManager
|
||||
}
|
||||
|
||||
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart)
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new AbortError("Restart delayed event timed out before the HS responded"));
|
||||
}, this.delayedLeaveEventRestartLocalTimeoutMs);
|
||||
});
|
||||
|
||||
// The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded
|
||||
// to the widget driver this information would ge lost. That is why we mimic the AbortError using the race.
|
||||
return await Promise.race([
|
||||
this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart),
|
||||
abortPromise,
|
||||
])
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
|
||||
return createInsertActionUpdate(
|
||||
@@ -594,8 +625,21 @@ export class MembershipManager
|
||||
this.state.expireUpdateIterations = 1;
|
||||
this.state.hasMemberStateEvent = true;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendJoinEvent);
|
||||
// An UpdateExpiry action might be left over from a previous join event.
|
||||
// We can reach sendJoinEvent when the delayed leave event gets send by the HS.
|
||||
// The branch where we might have a leftover UpdateExpiry action is:
|
||||
// RestartDelayedEvent (cannot find it, server removed it)
|
||||
// -> SendDelayedEvent (send new delayed event)
|
||||
// -> SendJoinEvent (here with a still scheduled UpdateExpiry action)
|
||||
const actionsWithoutUpdateExpiry = this.scheduler.actions.filter(
|
||||
(a) =>
|
||||
a.type !== MembershipActionType.UpdateExpiry && // A new UpdateExpiry action with an updated will be scheduled,
|
||||
a.type !== MembershipActionType.SendJoinEvent, // Manually remove the SendJoinEvent action,
|
||||
);
|
||||
return {
|
||||
insert: [
|
||||
replace: [
|
||||
...actionsWithoutUpdateExpiry,
|
||||
// To check if the delayed event is still there or got removed by inserting the stateEvent, we need to restart it.
|
||||
{ ts: Date.now(), type: MembershipActionType.RestartDelayedEvent },
|
||||
{
|
||||
ts: this.computeNextExpiryActionTs(this.state.expireUpdateIterations),
|
||||
@@ -657,7 +701,7 @@ export class MembershipManager
|
||||
|
||||
// HELPERS
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
@@ -670,9 +714,10 @@ export class MembershipManager
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
|
||||
application: this.sessionDescription.application,
|
||||
call_id: this.sessionDescription.id,
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: this.deviceId,
|
||||
expires,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
@@ -775,14 +820,19 @@ export class MembershipManager
|
||||
private actionUpdateFromNetworkErrorRetry(error: unknown, type: MembershipActionType): ActionUpdate | undefined {
|
||||
// "Is a network error"-boundary
|
||||
const retries = this.state.networkErrorRetries.get(type) ?? 0;
|
||||
|
||||
// Strings for error logging
|
||||
const retryDurationString = this.networkErrorRetryMs / 1000 + "s";
|
||||
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
||||
|
||||
// Variables for scheduling the new event
|
||||
let retryDuration = this.networkErrorRetryMs;
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// We do not wait for the timeout on local timeouts.
|
||||
retryDuration = 0;
|
||||
this.logger.warn(
|
||||
"Network local timeout error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
retryCounterString,
|
||||
"Network local timeout error while sending event, immediate retry (" + retryCounterString + ")",
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof Error && error.message.includes("updating delayed event")) {
|
||||
@@ -825,7 +875,7 @@ export class MembershipManager
|
||||
// retry boundary
|
||||
if (retries < this.maximumNetworkErrorRetryCount) {
|
||||
this.state.networkErrorRetries.set(type, retries + 1);
|
||||
return createInsertActionUpdate(type, this.networkErrorRetryMs);
|
||||
return createInsertActionUpdate(type, retryDuration);
|
||||
}
|
||||
|
||||
// Failure
|
||||
|
||||
@@ -46,6 +46,18 @@ import {
|
||||
* XXX In the future we want to distribute a ratcheted key not the current one for new joiners.
|
||||
*/
|
||||
export class RTCEncryptionManager implements IEncryptionManager {
|
||||
// This is a stop-gap solution for now. The preferred way to handle this case would be instead
|
||||
// to create a NoOpEncryptionManager that does nothing and use it for the session.
|
||||
// This will be done when removing the legacy EncryptionManager.
|
||||
private manageMediaKeys = false;
|
||||
|
||||
/**
|
||||
* Store the key rings for each participant.
|
||||
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
|
||||
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
|
||||
*/
|
||||
private participantKeyRings = new Map<ParticipantId, Array<{ key: Uint8Array; keyIndex: number }>>();
|
||||
|
||||
// The current per-sender media key for this device
|
||||
private outboundSession: OutboundEncryptionSession | null = null;
|
||||
|
||||
@@ -53,12 +65,24 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
* Ensures that there is only one distribute operation at a time for that call.
|
||||
*/
|
||||
private currentKeyDistributionPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* The time to wait before using the outbound session after it has been distributed.
|
||||
* This is to ensure that the key is delivered to all participants before it is used.
|
||||
* When creating the first key, this is set to 0, so that the key can be used immediately.
|
||||
* When creating the first key, this is set to 0 so that the key can be used immediately.
|
||||
*/
|
||||
private delayRolloutTimeMillis = 1000;
|
||||
private useKeyDelay = 5000;
|
||||
|
||||
/**
|
||||
* We want to avoid rolling out a new outbound key when the previous one was created less than `keyRotationGracePeriodMs` milliseconds ago.
|
||||
* This is to avoid expensive key rotations when users quickly join the call in a row.
|
||||
*
|
||||
* This must be higher than `useKeyDelay` to have an effect.
|
||||
* If it is lower, the current key will always be older than the grace period.
|
||||
* @private
|
||||
*/
|
||||
private keyRotationGracePeriodMs = 10_000;
|
||||
|
||||
/**
|
||||
* If a new key distribution is being requested while one is going on, we will set this flag to true.
|
||||
* This will ensure that a new round is started after the current one.
|
||||
@@ -94,14 +118,24 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
// This is deprecated should be ignored. Only used by tests?
|
||||
return new Map();
|
||||
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
|
||||
return new Map(this.participantKeyRings);
|
||||
}
|
||||
|
||||
private addKeyToParticipant(key: Uint8Array, keyIndex: number, participantId: ParticipantId): void {
|
||||
if (!this.participantKeyRings.has(participantId)) {
|
||||
this.participantKeyRings.set(participantId, []);
|
||||
}
|
||||
this.participantKeyRings.get(participantId)!.push({ key, keyIndex });
|
||||
this.onEncryptionKeysChanged(key, keyIndex, participantId);
|
||||
}
|
||||
|
||||
public join(joinConfig: EncryptionConfig | undefined): void {
|
||||
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
|
||||
|
||||
this.logger?.info(`Joining room`);
|
||||
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
@@ -114,6 +148,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
public leave(): void {
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
this.participantKeyRings.clear();
|
||||
}
|
||||
|
||||
// Temporary for backwards compatibility
|
||||
@@ -138,6 +173,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Will ensure that a new key is distributed and used to encrypt our media.
|
||||
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
|
||||
@@ -145,6 +181,10 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
* the calls will be coalesced to a single new distribution (that will start just after the current one has completed).
|
||||
*/
|
||||
private ensureKeyDistribution(): void {
|
||||
// `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead
|
||||
// to create a NoOpEncryptionManager that does nothing and use it for the session.
|
||||
// This will be done when removing the legacy EncryptionManager.
|
||||
if (!this.manageMediaKeys) return;
|
||||
if (this.currentKeyDistributionPromise == null) {
|
||||
this.logger?.debug(`No active rollout, start a new one`);
|
||||
// start a rollout
|
||||
@@ -167,6 +207,15 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
// `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead
|
||||
// to create a NoOpEncryptionManager that does nothing and use it for the session.
|
||||
// This will be done when removing the legacy EncryptionManager.
|
||||
if (!this.manageMediaKeys) {
|
||||
this.logger?.warn(
|
||||
`Received key over transport ${userId}:${deviceId} at index ${index} but media keys are disabled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger?.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`);
|
||||
|
||||
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
|
||||
@@ -181,7 +230,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
|
||||
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
|
||||
if (!outdated) {
|
||||
this.onEncryptionKeysChanged(
|
||||
this.addKeyToParticipant(
|
||||
candidateInboundSession.key,
|
||||
candidateInboundSession.keyIndex,
|
||||
candidateInboundSession.participantId,
|
||||
@@ -195,9 +244,9 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
/**
|
||||
* Called when the membership of the call changes.
|
||||
* This encryption manager is very basic, it will rotate the key everytime this is called.
|
||||
* @param oldMemberships
|
||||
* @param oldMemberships - This parameter is not used here, but it is kept for compatibility with the interface.
|
||||
*/
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[] = []): void {
|
||||
this.logger?.trace(`onMembershipsUpdate`);
|
||||
|
||||
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
|
||||
@@ -215,7 +264,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
sharedWith: [],
|
||||
keyId: 0,
|
||||
};
|
||||
this.onEncryptionKeysChanged(
|
||||
this.addKeyToParticipant(
|
||||
this.outboundSession.key,
|
||||
this.outboundSession.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
@@ -264,26 +313,28 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
let hasKeyChanged = false;
|
||||
if (anyLeft.length > 0) {
|
||||
// We need to rotate the key
|
||||
const newOutboundKey: OutboundEncryptionSession = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: this.nextKeyIndex(),
|
||||
};
|
||||
const newOutboundKey = this.createNewOutboundSession();
|
||||
hasKeyChanged = true;
|
||||
|
||||
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
|
||||
// Set this new key as the current one
|
||||
this.outboundSession = newOutboundKey;
|
||||
|
||||
// Send
|
||||
toDistributeTo = toShareWith;
|
||||
outboundKey = newOutboundKey;
|
||||
} else if (anyJoined.length > 0) {
|
||||
// keep the same key
|
||||
// XXX In the future we want to distribute a ratcheted key not the current one
|
||||
toDistributeTo = anyJoined;
|
||||
outboundKey = this.outboundSession!;
|
||||
const now = Date.now();
|
||||
const keyAge = now - this.outboundSession!.creationTS;
|
||||
// If the current key is recently created (less than `keyRotationGracePeriodMs`), we can keep it and just distribute it to the new joiners.
|
||||
if (keyAge < this.keyRotationGracePeriodMs) {
|
||||
// keep the same key
|
||||
// XXX In the future we want to distribute a ratcheted key, not the current one
|
||||
this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
|
||||
toDistributeTo = anyJoined;
|
||||
outboundKey = this.outboundSession!;
|
||||
} else {
|
||||
// We need to rotate the key
|
||||
this.logger?.debug(`New joiners detected, rotating the key`);
|
||||
const newOutboundKey = this.createNewOutboundSession();
|
||||
hasKeyChanged = true;
|
||||
toDistributeTo = toShareWith;
|
||||
outboundKey = newOutboundKey;
|
||||
}
|
||||
} else {
|
||||
// no changes
|
||||
return;
|
||||
@@ -301,9 +352,9 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
// Delay a bit before using this key
|
||||
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
|
||||
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
|
||||
await sleep(this.delayRolloutTimeMillis);
|
||||
await sleep(this.useKeyDelay);
|
||||
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
|
||||
this.onEncryptionKeysChanged(
|
||||
this.addKeyToParticipant(
|
||||
outboundKey.key,
|
||||
outboundKey.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
@@ -314,6 +365,20 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private createNewOutboundSession(): OutboundEncryptionSession {
|
||||
const newOutboundKey: OutboundEncryptionSession = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: this.nextKeyIndex(),
|
||||
};
|
||||
|
||||
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
|
||||
// Set this new key as the current one
|
||||
this.outboundSession = newOutboundKey;
|
||||
return newOutboundKey;
|
||||
}
|
||||
|
||||
private nextKeyIndex(): number {
|
||||
if (this.outboundSession) {
|
||||
return (this.outboundSession!.keyId + 1) % 256;
|
||||
|
||||
+16
-2
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { RelationEvent } from "../types.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
|
||||
export type ParticipantId = string;
|
||||
@@ -80,9 +81,13 @@ export interface EncryptionKeysToDeviceEventContent {
|
||||
// Why is this needed?
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `RTCNotificationType` instead.
|
||||
*/
|
||||
export type CallNotifyType = "ring" | "notify";
|
||||
|
||||
/**
|
||||
* @deprecated Use `IRTCNotificationContent` instead.
|
||||
*/
|
||||
export interface ICallNotifyContent {
|
||||
"application": string;
|
||||
"m.mentions": IMentions;
|
||||
@@ -90,6 +95,15 @@ export interface ICallNotifyContent {
|
||||
"call_id": string;
|
||||
}
|
||||
|
||||
export type RTCNotificationType = "ring" | "notification";
|
||||
export interface IRTCNotificationContent extends RelationEvent {
|
||||
"m.mentions": IMentions;
|
||||
"decline_reason"?: string;
|
||||
"notification_type": RTCNotificationType;
|
||||
"sender_ts": number;
|
||||
"lifetime": number;
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
Disconnected = "Disconnected",
|
||||
Connecting = "Connecting",
|
||||
|
||||
@@ -66,7 +66,6 @@ export type RoomMemberEventHandlerMap = {
|
||||
* ```
|
||||
* matrixClient.on("RoomMember.powerLevel", function(event, member){
|
||||
* var newPowerLevel = member.powerLevel;
|
||||
* var newNormPowerLevel = member.powerLevelNorm;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -109,10 +108,6 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
* The power level for this room member.
|
||||
*/
|
||||
public powerLevel = 0;
|
||||
/**
|
||||
* The normalised power level (0-100) for this room member.
|
||||
*/
|
||||
public powerLevelNorm = 0;
|
||||
/**
|
||||
* The User object for this room member, if one exists.
|
||||
*/
|
||||
@@ -226,43 +221,18 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this room member's power level event. May fire
|
||||
* "RoomMember.powerLevel" if this event updates this member's power levels.
|
||||
* @param powerLevelEvent - The `m.room.power_levels` event
|
||||
* Update this room member's power level event. Will fire
|
||||
* "RoomMember.powerLevel" if the new power level is different
|
||||
* @param powerLevel - The power level of the room member.
|
||||
*
|
||||
* @remarks
|
||||
* Fires {@link RoomMemberEvent.PowerLevel}
|
||||
*/
|
||||
public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void {
|
||||
if (powerLevelEvent.getType() !== EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const evContent = powerLevelEvent.getDirectionalContent();
|
||||
|
||||
let maxLevel = evContent.users_default || 0;
|
||||
const users: { [userId: string]: number } = evContent.users || {};
|
||||
Object.values(users).forEach((lvl: number) => {
|
||||
maxLevel = Math.max(maxLevel, lvl);
|
||||
});
|
||||
public setPowerLevel(powerLevel: number, powerLevelEvent: MatrixEvent): void {
|
||||
const oldPowerLevel = this.powerLevel;
|
||||
const oldPowerLevelNorm = this.powerLevelNorm;
|
||||
this.powerLevel = powerLevel;
|
||||
|
||||
if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) {
|
||||
this.powerLevel = users[this.userId];
|
||||
} else if (evContent.users_default !== undefined) {
|
||||
this.powerLevel = evContent.users_default;
|
||||
} else {
|
||||
this.powerLevel = 0;
|
||||
}
|
||||
this.powerLevelNorm = 0;
|
||||
if (maxLevel > 0) {
|
||||
this.powerLevelNorm = (this.powerLevel * 100) / maxLevel;
|
||||
}
|
||||
|
||||
// emit for changes in powerLevelNorm as well (since the app will need to
|
||||
// redraw everyone's level if the max has changed)
|
||||
if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) {
|
||||
if (oldPowerLevel !== this.powerLevel) {
|
||||
this.updateModifiedTime();
|
||||
this.emit(RoomMemberEvent.PowerLevel, powerLevelEvent, this);
|
||||
}
|
||||
|
||||
+86
-12
@@ -33,6 +33,7 @@ import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type RoomJoinRulesEventContent } from "../@types/state_events.ts";
|
||||
import { shouldUseHydraForRoomVersion } from "../utils/roomVersion.ts";
|
||||
|
||||
export interface IMarkerFoundOptions {
|
||||
/** Whether the timeline was empty before the marker event arrived in the
|
||||
@@ -173,6 +174,9 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
|
||||
private _liveBeaconIds: BeaconIdentifier[] = [];
|
||||
|
||||
// We only wants to print warnings about bad room state once.
|
||||
private getVersionWarning = false;
|
||||
|
||||
/**
|
||||
* Construct room state.
|
||||
*
|
||||
@@ -209,6 +213,22 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
this.updateModifiedTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the version of the room
|
||||
* @returns The version of the room
|
||||
*/
|
||||
public getRoomVersion(): string {
|
||||
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
|
||||
if (!createEvent) {
|
||||
if (!this.getVersionWarning) {
|
||||
logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event");
|
||||
this.getVersionWarning = true;
|
||||
}
|
||||
return "1";
|
||||
}
|
||||
return createEvent.getContent()["room_version"] ?? "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of joined members in this room
|
||||
* This method caches the result.
|
||||
@@ -468,12 +488,20 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
return;
|
||||
}
|
||||
const members = Object.values(this.members);
|
||||
|
||||
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
|
||||
const creators = getCreators(this.getRoomVersion(), createEvent);
|
||||
|
||||
members.forEach((member) => {
|
||||
// We only propagate `RoomState.members` event if the
|
||||
// power levels has been changed
|
||||
// large room suffer from large re-rendering especially when not needed
|
||||
const oldLastModified = member.getLastModifiedTime();
|
||||
member.setPowerLevelEvent(event);
|
||||
|
||||
if (createEvent) {
|
||||
const pl = powerLevelForUserId(member.userId, event, creators);
|
||||
member.setPowerLevel(pl, event);
|
||||
}
|
||||
if (oldLastModified !== member.getLastModifiedTime()) {
|
||||
this.emit(RoomStateEvent.Members, event, this, member);
|
||||
}
|
||||
@@ -625,9 +653,16 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
|
||||
private updateMember(member: RoomMember): void {
|
||||
// this member may have a power level already, so set it.
|
||||
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
|
||||
const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
|
||||
if (pwrLvlEvent) {
|
||||
member.setPowerLevelEvent(pwrLvlEvent);
|
||||
if (pwrLvlEvent && createEvent) {
|
||||
const powerLevel = powerLevelForUserId(
|
||||
member.userId,
|
||||
pwrLvlEvent,
|
||||
getCreators(this.getRoomVersion(), createEvent),
|
||||
);
|
||||
|
||||
member.setPowerLevel(powerLevel, pwrLvlEvent);
|
||||
}
|
||||
|
||||
// blow away the sentinel which is now outdated
|
||||
@@ -904,7 +939,6 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
|
||||
let stateDefault = 0;
|
||||
let eventsDefault = 0;
|
||||
let powerLevel = 0;
|
||||
if (powerLevelsEvent) {
|
||||
powerLevels = powerLevelsEvent.getContent();
|
||||
eventsLevels = powerLevels.events || {};
|
||||
@@ -915,13 +949,6 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
stateDefault = 50;
|
||||
}
|
||||
|
||||
const userPowerLevel = powerLevels.users && powerLevels.users[userId];
|
||||
if (Number.isSafeInteger(userPowerLevel)) {
|
||||
powerLevel = userPowerLevel!;
|
||||
} else if (Number.isSafeInteger(powerLevels.users_default)) {
|
||||
powerLevel = powerLevels.users_default!;
|
||||
}
|
||||
|
||||
if (Number.isSafeInteger(powerLevels.events_default)) {
|
||||
eventsDefault = powerLevels.events_default!;
|
||||
}
|
||||
@@ -931,7 +958,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
if (Number.isSafeInteger(eventsLevels[eventType])) {
|
||||
requiredLevel = eventsLevels[eventType];
|
||||
}
|
||||
return powerLevel >= requiredLevel;
|
||||
|
||||
const roomMember = this.getMember(userId);
|
||||
const userPowerLevel = roomMember?.powerLevel ?? 0;
|
||||
|
||||
return userPowerLevel >= requiredLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1106,3 +1137,46 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of creator user IDs for a room: empty if the room is not a 'hydra' room, otherwise
|
||||
* computed from the sender of the m.room.create event plus the additional_creators field.
|
||||
* @param roomVersion The version of the room
|
||||
* @param roomCreateEvent The m.room.create event for the room
|
||||
* @returns A set of user IDs of the creators of the room.
|
||||
*/
|
||||
function getCreators(roomVersion: string, roomCreateEvent: MatrixEvent | null): Set<string> {
|
||||
const creators = new Set<string>();
|
||||
if (shouldUseHydraForRoomVersion(roomVersion) && roomCreateEvent) {
|
||||
const roomCreateSender = roomCreateEvent.getSender();
|
||||
if (roomCreateSender) creators.add(roomCreateSender);
|
||||
const additionalCreators = roomCreateEvent.getDirectionalContent().additional_creators;
|
||||
if (Array.isArray(additionalCreators)) additionalCreators.forEach((c) => creators.add(c));
|
||||
}
|
||||
return creators;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId The user ID to compute the power level for
|
||||
* @param powerLevelEvents The power level event for the room
|
||||
* @param creators The set of creator user IDs for the room if the room is a 'hydra' room, otherwise the empty set.
|
||||
*/
|
||||
function powerLevelForUserId(userId: string, powerLevelEvent: MatrixEvent, creators: Set<string>): number {
|
||||
if (creators.has(userId)) {
|
||||
// As of "Hydra", If the user is a creator, they always have the highest power level
|
||||
return Infinity;
|
||||
} else {
|
||||
const evContent = powerLevelEvent.getDirectionalContent();
|
||||
|
||||
const users: { [userId: string]: number } = evContent.users || {};
|
||||
|
||||
if (users[userId] !== undefined && Number.isInteger(users[userId])) {
|
||||
return users[userId];
|
||||
} else if (evContent.users_default !== undefined) {
|
||||
return evContent.users_default;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -374,7 +374,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
private heroes: Hero[] | null = null;
|
||||
// flags to stop logspam about missing m.room.create events
|
||||
private getTypeWarning = false;
|
||||
private getVersionWarning = false;
|
||||
private membersPromise?: Promise<boolean>;
|
||||
|
||||
// XXX: These should be read-only
|
||||
@@ -606,18 +605,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
/**
|
||||
* Gets the version of the room
|
||||
* @returns The version of the room, or null if it could not be determined
|
||||
* @returns The version of the room
|
||||
*/
|
||||
public getVersion(): string {
|
||||
const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
if (!createEvent) {
|
||||
if (!this.getVersionWarning) {
|
||||
logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event");
|
||||
this.getVersionWarning = true;
|
||||
}
|
||||
return "1";
|
||||
}
|
||||
return createEvent.getContent()["room_version"] ?? "1";
|
||||
return this.currentState.getRoomVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,8 @@ export const generateAuthorizationUrl = async (
|
||||
* @param prompt - indicates to the OP which flow the user should see - eg login or registration
|
||||
* See https://openid.net/specs/openid-connect-prompt-create-1_0.html#name-prompt-parameter
|
||||
* @param urlState - value to append to the opaque state identifier to uniquely identify the callback
|
||||
* @param loginHint - value to send as the `login_hint` to the OP, giving a hint about the login identifier the user might use to log in.
|
||||
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
|
||||
* @returns a Promise with the url as a string
|
||||
*/
|
||||
export const generateOidcAuthorizationUrl = async ({
|
||||
@@ -136,6 +138,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
nonce,
|
||||
prompt,
|
||||
urlState,
|
||||
loginHint,
|
||||
}: {
|
||||
clientId: string;
|
||||
metadata: ValidatedAuthMetadata;
|
||||
@@ -145,6 +148,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
nonce: string;
|
||||
prompt?: string;
|
||||
urlState?: string;
|
||||
loginHint?: string;
|
||||
}): Promise<string> => {
|
||||
const scope = generateScope();
|
||||
const oidcClient = new OidcClient({
|
||||
@@ -163,6 +167,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
nonce,
|
||||
prompt,
|
||||
url_state: urlState,
|
||||
login_hint: loginHint,
|
||||
});
|
||||
|
||||
return request.url;
|
||||
|
||||
@@ -98,19 +98,16 @@ export async function initRustCrypto(args: {
|
||||
logger.debug("Initialising Rust crypto-sdk WASM artifact");
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
logger.debug("Opening Rust CryptoStore");
|
||||
let storeHandle;
|
||||
if (args.storePrefix) {
|
||||
if (args.storeKey) {
|
||||
storeHandle = await StoreHandle.openWithKey(args.storePrefix, args.storeKey);
|
||||
storeHandle = await StoreHandle.openWithKey(args.storePrefix, args.storeKey, logger);
|
||||
} else {
|
||||
storeHandle = await StoreHandle.open(args.storePrefix, args.storePassphrase);
|
||||
storeHandle = await StoreHandle.open(args.storePrefix, args.storePassphrase, logger);
|
||||
}
|
||||
} else {
|
||||
storeHandle = await StoreHandle.open();
|
||||
storeHandle = await StoreHandle.open(null, null, logger);
|
||||
}
|
||||
|
||||
if (args.legacyCryptoStore) {
|
||||
@@ -155,6 +152,7 @@ async function initOlmMachine(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.DeviceId(deviceId),
|
||||
storeHandle,
|
||||
logger,
|
||||
);
|
||||
|
||||
// A final migration step, now that we have an OlmMachine.
|
||||
|
||||
@@ -80,9 +80,6 @@ export async function migrateFromLegacyCrypto(args: {
|
||||
// initialise the rust matrix-sdk-crypto-wasm, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
if (!(await legacyStore.containsData())) {
|
||||
// This store was never used. Nothing to migrate.
|
||||
return;
|
||||
@@ -230,7 +227,7 @@ async function migrateBaseData(
|
||||
pickleKey,
|
||||
"user_signing",
|
||||
);
|
||||
await RustSdkCryptoJs.Migration.migrateBaseData(migrationData, pickleKey, storeHandle);
|
||||
await RustSdkCryptoJs.Migration.migrateBaseData(migrationData, pickleKey, storeHandle, logger);
|
||||
}
|
||||
|
||||
async function countOlmSessions(logger: Logger, legacyStore: CryptoStore): Promise<number> {
|
||||
@@ -269,7 +266,7 @@ async function migrateOlmSessions(
|
||||
migrationData.push(pickledSession);
|
||||
}
|
||||
|
||||
await RustSdkCryptoJs.Migration.migrateOlmSessions(migrationData, pickleKey, storeHandle);
|
||||
await RustSdkCryptoJs.Migration.migrateOlmSessions(migrationData, pickleKey, storeHandle, logger);
|
||||
await legacyStore.deleteEndToEndSessionsBatch(batch);
|
||||
onBatchDone(batch.length);
|
||||
}
|
||||
@@ -343,7 +340,7 @@ async function migrateMegolmSessions(
|
||||
migrationData.push(pickledSession);
|
||||
}
|
||||
|
||||
await RustSdkCryptoJs.Migration.migrateMegolmSessions(migrationData, pickleKey, storeHandle);
|
||||
await RustSdkCryptoJs.Migration.migrateMegolmSessions(migrationData, pickleKey, storeHandle, logger);
|
||||
await legacyStore.deleteEndToEndInboundGroupSessionsBatch(batch);
|
||||
onBatchDone(batch.length);
|
||||
}
|
||||
|
||||
+128
-17
@@ -20,7 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts";
|
||||
import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts";
|
||||
import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts";
|
||||
import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomMember } from "../models/room-member.ts";
|
||||
@@ -30,13 +30,14 @@ import {
|
||||
DecryptionError,
|
||||
type OnSyncCompletedData,
|
||||
} from "../common-crypto/CryptoBackend.ts";
|
||||
import { logger, type Logger, LogSpan } from "../logger.ts";
|
||||
import { type Logger, LogSpan } from "../logger.ts";
|
||||
import { type IHttpOpts, type MatrixHttpApi, Method } from "../http-api/index.ts";
|
||||
import { RoomEncryptor } from "./RoomEncryptor.ts";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
|
||||
import { KeyClaimManager } from "./KeyClaimManager.ts";
|
||||
import { MapWithDefault } from "../utils.ts";
|
||||
import {
|
||||
AllDevicesIsolationMode,
|
||||
type BackupTrustInfo,
|
||||
type BootstrapCrossSigningOpts,
|
||||
type CreateSecretStorageOpts,
|
||||
@@ -45,29 +46,28 @@ import {
|
||||
type CrossSigningStatus,
|
||||
type CryptoApi,
|
||||
type CryptoCallbacks,
|
||||
CryptoEvent,
|
||||
type CryptoEventHandlerMap,
|
||||
DecryptionFailureCode,
|
||||
deriveRecoveryKeyFromPassphrase,
|
||||
type DeviceIsolationMode,
|
||||
DeviceIsolationModeKind,
|
||||
DeviceVerificationStatus,
|
||||
encodeRecoveryKey,
|
||||
type EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
type GeneratedSecretStorageKey,
|
||||
type ImportRoomKeysOpts,
|
||||
ImportRoomKeyStage,
|
||||
type KeyBackupCheck,
|
||||
type KeyBackupInfo,
|
||||
type OwnDeviceKeys,
|
||||
UserVerificationStatus,
|
||||
type VerificationRequest,
|
||||
encodeRecoveryKey,
|
||||
deriveRecoveryKeyFromPassphrase,
|
||||
type DeviceIsolationMode,
|
||||
AllDevicesIsolationMode,
|
||||
DeviceIsolationModeKind,
|
||||
CryptoEvent,
|
||||
type CryptoEventHandlerMap,
|
||||
type KeyBackupRestoreOpts,
|
||||
type KeyBackupRestoreResult,
|
||||
type OwnDeviceKeys,
|
||||
type StartDehydrationOpts,
|
||||
ImportRoomKeyStage,
|
||||
UserVerificationStatus,
|
||||
type VerificationRequest,
|
||||
} from "../crypto-api/index.ts";
|
||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts";
|
||||
import { type IDownloadKeyResult, type IQueryKeysRequest } from "../client.ts";
|
||||
@@ -94,6 +94,7 @@ import { DehydratedDeviceManager } from "./DehydratedDeviceManager.ts";
|
||||
import { VerificationMethod } from "../types.ts";
|
||||
import { keyFromAuthData } from "../common-crypto/key-passphrase.ts";
|
||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||
import { getHttpUriForMxc } from "../content-repo.ts";
|
||||
|
||||
const ALL_VERIFICATION_METHODS = [
|
||||
VerificationMethod.Sas,
|
||||
@@ -321,6 +322,62 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend.maybeAcceptKeyBundle}.
|
||||
*/
|
||||
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void> {
|
||||
// TODO: retry this if it gets interrupted or it fails.
|
||||
// TODO: do this in the background.
|
||||
// TODO: handle the bundle message arriving after the invite.
|
||||
|
||||
const logger = new LogSpan(this.logger, `maybeAcceptKeyBundle(${roomId}, ${inviter})`);
|
||||
|
||||
const bundleData = await this.olmMachine.getReceivedRoomKeyBundleData(
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
new RustSdkCryptoJs.UserId(inviter),
|
||||
);
|
||||
if (!bundleData) {
|
||||
logger.info("No key bundle found for user");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Fetching key bundle ${bundleData.url}`);
|
||||
const url = getHttpUriForMxc(
|
||||
this.http.opts.baseUrl,
|
||||
bundleData.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
/* allowDirectLinks */ false,
|
||||
/* allowRedirects */ true,
|
||||
/* useAuthentication */ true,
|
||||
);
|
||||
let encryptedBundle: Blob;
|
||||
try {
|
||||
const bundleUrl = new URL(url);
|
||||
encryptedBundle = await this.http.authedRequest<Blob>(
|
||||
Method.Get,
|
||||
bundleUrl.pathname + bundleUrl.search,
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
rawResponseBody: true,
|
||||
prefix: "",
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`Error downloading encrypted bundle from ${url}:`, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info(`Received blob of length ${encryptedBundle.size}`);
|
||||
try {
|
||||
await this.olmMachine.receiveRoomKeyBundle(bundleData, new Uint8Array(await encryptedBundle.arrayBuffer()));
|
||||
} catch (err) {
|
||||
logger.warn(`Error receiving encrypted bundle:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// CryptoApi implementation
|
||||
@@ -826,18 +883,18 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
private async saveBackupKeyToStorage(): Promise<void> {
|
||||
const keyBackupInfo = await this.backupManager.getServerBackupInfo();
|
||||
if (!keyBackupInfo || !keyBackupInfo.version) {
|
||||
logger.info("Not saving backup key to secret storage: no backup info");
|
||||
this.logger.info("Not saving backup key to secret storage: no backup info");
|
||||
return;
|
||||
}
|
||||
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
if (!backupKeys.decryptionKey) {
|
||||
logger.info("Not saving backup key to secret storage: no backup key");
|
||||
this.logger.info("Not saving backup key to secret storage: no backup key");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!decryptionKeyMatchesKeyBackupInfo(backupKeys.decryptionKey, keyBackupInfo)) {
|
||||
logger.info("Not saving backup key to secret storage: decryption key does not match backup info");
|
||||
this.logger.info("Not saving backup key to secret storage: decryption key does not match backup info");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1262,7 +1319,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
if (info?.version) {
|
||||
await this.deleteKeyBackupVersion(info.version);
|
||||
} else {
|
||||
logger.error("Can't delete key backup version: no version available");
|
||||
this.logger.error("Can't delete key backup version: no version available");
|
||||
}
|
||||
|
||||
// also turn off 4S, since this is also storing keys on the server.
|
||||
@@ -1474,6 +1531,54 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
await this.secretStorage.setDefaultKeyId(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#shareRoomHistoryWithUser}.
|
||||
*/
|
||||
public async shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void> {
|
||||
const logger = new LogSpan(this.logger, `shareRoomHistoryWithUser(${roomId}, ${userId})`);
|
||||
|
||||
// 0. We can only share room history if our user has set up cross-signing.
|
||||
const identity = await this.getOwnIdentity();
|
||||
if (!identity?.isVerified()) {
|
||||
logger.warn(
|
||||
"Not sharing message history as the current device is not verified by our cross-signing identity",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Sharing message history");
|
||||
|
||||
// 1. Construct the key bundle
|
||||
const bundle = await this.getOlmMachineOrThrow().buildRoomKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
|
||||
if (!bundle) {
|
||||
logger.info("No keys to share");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Upload the encrypted bundle to the server
|
||||
const uploadResponse = await this.http.uploadContent(bundle.encryptedData);
|
||||
logger.info(`Uploaded encrypted key blob: ${JSON.stringify(uploadResponse)}`);
|
||||
|
||||
// 3. We may not share a room with the user, so get a fresh list of devices for the invited user.
|
||||
const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(userId)]);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
|
||||
// 4. Establish Olm sessions with all of the recipient's devices.
|
||||
await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(userId)]);
|
||||
|
||||
// 5. Send to-device messages to the recipient to share the keys.
|
||||
const requests = await this.getOlmMachineOrThrow().shareRoomKeyBundleData(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
uploadResponse.content_uri,
|
||||
bundle.mediaEncryptionInfo,
|
||||
RustSdkCryptoJs.CollectStrategy.identityBasedStrategy(),
|
||||
);
|
||||
for (const req of requests) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -2277,6 +2382,12 @@ function rustEncryptionInfoToJsEncryptionInfo(
|
||||
case RustSdkCryptoJs.ShieldStateCode.VerificationViolation:
|
||||
shieldReason = EventShieldReason.VERIFICATION_VIOLATION;
|
||||
break;
|
||||
case RustSdkCryptoJs.ShieldStateCode.MismatchedSender:
|
||||
shieldReason = EventShieldReason.MISMATCHED_SENDER;
|
||||
break;
|
||||
default:
|
||||
shieldReason = EventShieldReason.UNKNOWN;
|
||||
break;
|
||||
}
|
||||
|
||||
return { shieldColour, shieldReason };
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Room versions strings that we know about and do not use hydra semantics.
|
||||
*/
|
||||
const HYDRA_ROOM_VERSIONS = ["org.matrix.hydra.11", "12"];
|
||||
|
||||
/**
|
||||
* Checks if the given room version is one where new "hydra" power level
|
||||
* semantics (ie. room version 12 or later) should be used
|
||||
* (see https://github.com/matrix-org/matrix-spec-proposals/pull/4289).
|
||||
* This will return `true` for versions that are known to the js-sdk and
|
||||
* use hydra: any room versions unknown to the js-sdk (experimental or
|
||||
* otherwise) will cause the function to return `false`.
|
||||
*
|
||||
* @param roomVersion - The version of the room to check.
|
||||
* @returns `true` if hydra semantics should be used for the room version, `false` otherwise.
|
||||
*/
|
||||
export function shouldUseHydraForRoomVersion(roomVersion: string): boolean {
|
||||
// Future new room versions must obviously be added to the constant above,
|
||||
// otherwise the js-sdk will use the old, pre-hydra semantics. At some point
|
||||
// it would make sense to assume hydra for unknown versions but this will break
|
||||
// any rooms using unknown versions, so at hydra switch time we've agreed all
|
||||
// Element clients will only use hydra for the two specific hydra versions.
|
||||
return HYDRA_ROOM_VERSIONS.includes(roomVersion);
|
||||
}
|
||||
Reference in New Issue
Block a user