Compare commits

...

29 Commits

Author SHA1 Message Date
RiotRobot 6d046edcb2 v38.0.0-rc.0 2025-08-19 13:08:13 +00:00
Timo 2abf7ca795 Allow multiple rtc sessions per room (with different sessionDescriptions) (#4945)
* Introduce sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* Make sessionDescription part of a MatrixRTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* Make session manager only menage session for one sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* make membership manager aware about session (application + id)
Before this was just hardcoded to a call session

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix doc comments

Signed-off-by: Timo K <toger5@hotmail.de>

* Make fields private,  improve comments, improve whitespace, don't use deprecated fields

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for other application end event

Signed-off-by: Timo K <toger5@hotmail.de>

* rename call -> session

Signed-off-by: Timo K <toger5@hotmail.de>

* fix tests

Signed-off-by: Timo K <toger5@hotmail.de>

* remove id check since its already part of `deepCompare(membership.sessionDescription, sessionDescription)`

Signed-off-by: Timo K <toger5@hotmail.de>

* remove scope related tests. The id should be the only thing that scopes sessions. everything else is application (session type) specific

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for custom sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* callMembershipsForRoom to default to call

Signed-off-by: Timo K <toger5@hotmail.de>

* roomSessionForRoom backwards compatible (And deprecate the call specific method)

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-19 11:58:57 +00:00
Olivier D 2b46579bd8 Add support for login_hint in authorization url generation (#4943)
* add login_hint to authorization url generation

Signed-off-by: olivier <odelcroi@gmail.com>

Signed-off-by: olivier <odelcroi@gmail.com>

fix lint

Signed-off-by: olivier <odelcroi@gmail.com>

* update doc

* fix linter

---------

Signed-off-by: olivier <odelcroi@gmail.com>
Co-authored-by: mcalinghee <mcalinghee.dev@gmail.com>
2025-08-18 15:06:59 +00:00
fkwp 6d42ed338e Only process MatrixRTC sessions associated with calls for callMembershipsForRoom (#4960)
* Only process MatrixRTC sessions associated with calls

* tests: Only process MatrixRTC sessions associated with calls

* linting
2025-08-14 12:44:41 +00:00
RiotRobot ef080c25f9 Merge branch 'master' into develop 2025-08-11 11:13:57 +00:00
RiotRobot c8d7b458b2 v37.13.0 2025-08-11 11:13:14 +00:00
ElementRobot f1ba8a8775 Support v12 rooms in maySendEvent (#4955) (#4956)
Follows on from https://github.com/matrix-org/matrix-js-sdk/pull/4937

(cherry picked from commit dea184e9ec)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-08-08 15:17:12 +01:00
ElementRobot d21adf568a Support for creator power level (#4937) (#4954)
* Support for creator power level

Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289).

* Update unit test

* Hardcode versions

as room versions strings aren't ordered

* Add test for v12 rooms

* Use more compact syntax



* Fix doc



* Fix additionalCreators from PR edit

* Split out hydra room version check

* Move power level logic into room state

Which already has knowledge of the room create event

* Add docs

* Fix unused bits

* Fix docs

* Fix lying docstring

* Reverse logic for hydra semantics

Assume unknown room versions do use hydra

* Use backticks



* Switch back to hardcoding just the two hydra versions

---------



(cherry picked from commit e119bf9040)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-08-08 15:09:10 +01:00
David Baker dea184e9ec Support v12 rooms in maySendEvent (#4955)
Follows on from https://github.com/matrix-org/matrix-js-sdk/pull/4937
2025-08-08 13:19:51 +00:00
David Baker e119bf9040 Support for creator power level (#4937)
* Support for creator power level

Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289).

* Update unit test

* Hardcode versions

as room versions strings aren't ordered

* Add test for v12 rooms

* Use more compact syntax

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* Fix doc

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* Fix additionalCreators from PR edit

* Split out hydra room version check

* Move power level logic into room state

Which already has knowledge of the room create event

* Add docs

* Fix unused bits

* Fix docs

* Fix lying docstring

* Reverse logic for hydra semantics

Assume unknown room versions do use hydra

* Use backticks

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Switch back to hardcoding just the two hydra versions

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-08-08 10:23:49 +00:00
renovate[bot] c7f982e190 Update typedoc (#4949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 14:25:57 +00:00
renovate[bot] 2e2dd628c1 Update typescript-eslint monorepo to v8.39.0 (#4952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:56:03 +00:00
renovate[bot] 5ac5a8a799 Update dependency @babel/runtime to v7.28.2 (#4950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:55:49 +00:00
renovate[bot] 7d75ab417a Update dependency lint-staged to v16.1.4 (#4948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:40:14 +00:00
renovate[bot] 3ca81e409a Update dependency @types/node to v18.19.121 (#4947)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:40:03 +00:00
RiotRobot 764fdb1d30 v37.13.0-rc.0 2025-08-05 12:51:02 +00:00
Timo c2d25d9377 Fix possible unknown state (With reproducible test) (#4944)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-01 09:13:35 +00:00
Richard van der Hoff c4e1e0723e Experimental support for sharing encrypted history on invite (#4920)
* tests: Cross-signing keys support in `E2EKeyReceiver`

Have `E2EKeyReceiver` collect uploaded cross-signing keys, so that they can be
returned by `E2EKeyResponder`.

* tests: Signature upload support in `E2EKeyReceiver`

Have `E2EKeyReceiver` collect uploaded device signatures, so that they can be
returned by `E2EKeyResponder`.

* tests: Implement `E2EOTKClaimResponder` class

A new test helper, which intercepts `/keys/claim`, allowing clients under test
to claim OTKs uploaded by other devices.

* Expose experimental settings for encrypted history sharing

Add options to `MatrixClient.invite` and `MatrixClient.joinRoom` to share and
accept encrypted history on invite, per MSC4268.

* Clarify pre-join-membership logic

* Improve tests

* Update spec/integ/crypto/cross-signing.spec.ts

Co-authored-by: Hubert Chathi <hubertc@matrix.org>

---------

Co-authored-by: Hubert Chathi <hubertc@matrix.org>
2025-07-29 15:42:35 +00:00
RiotRobot 56b24c0bdc Merge branch 'master' into develop 2025-07-29 12:59:32 +00:00
Valere Fedronic c57c47319e MatrixRTC: comply with the manageMediaKeys EncryptionConfig option (#4942)
* MatrixRTC: comply with the `manageMediaKeys` JoinConfig option

* add additional test for reception

* add comments about temporary solution
2025-07-28 15:11:29 +00:00
Richard van der Hoff 812d0aaef6 Use js-sdk logger in rust sdk (#4918) 2025-07-25 09:57:25 +00:00
Richard van der Hoff 61e07633df Update to matrix-sdk-crypto-wasm 15.1.0, and add new ShieldStateCode.MismatchedSender (#4916)
* test: add a flushPromises

this seems to be needed because `initRustCrypto` now ends up doing slightly
less awaiting

* Support new `ShieldStateCode.MismatchedSender`

* Update to matrix-sdk-crypto-wasm 15.1.0

* Add `waitFor` and use it instead of `flushPromises`

* minor lints and fixes

* another lint fix
2025-07-24 13:35:55 +00:00
Richard van der Hoff c7dbd6e33b fetch api: add support for downloading raw response (#4917)
* Factor out `BaseRequestOpts`

... to make it easier to find the docs from methods that use it.

* fetch api: add support for downloading raw response

I need to make an authenticated request to the media repo, and expect to get a
binary file back. AFAICT there is no easy way to do that right now.

* Clarify doc strings

* Various fixes
2025-07-24 11:06:52 +00:00
Richard van der Hoff 556494b8f0 Attempt deflaking of queueToDevice test (#4936) 2025-07-24 09:20:54 +00:00
renovate[bot] bf3b4e81b2 Update typescript-eslint monorepo to v8.37.0 (#4935)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 16:36:31 +00:00
renovate[bot] 2710600389 Update all non-major dependencies (#4930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 16:12:13 +00:00
renovate[bot] ca168c494b Update dependency @types/node to v18.19.120 (#4931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 16:12:11 +00:00
renovate[bot] 759f5ed3eb Update dependency typedoc-plugin-mdn-links to v5.0.4 (#4932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 13:38:21 +00:00
renovate[bot] 53deedd2d6 Update dependency @stylistic/eslint-plugin to v5.2.0 (#4934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 13:37:49 +00:00
40 changed files with 1998 additions and 683 deletions
+21
View File
@@ -1,3 +1,24 @@
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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "37.12.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",
+9 -9
View File
@@ -137,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
@@ -225,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),
@@ -420,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 },
);
});
@@ -181,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()!;
+239
View File
@@ -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 {};
},
);
});
}
+96 -7
View File
@@ -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];
}
}
+22 -15
View File
@@ -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;
+73
View File
@@ -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);
}
}
+1 -16
View File
@@ -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",
},
{},
);
}
/**
+95
View File
@@ -592,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."));
}
}
});
}
+34 -1
View File
@@ -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 () => {
+54 -49
View File
@@ -26,6 +26,8 @@ const mockFocus = { type: "mock" };
const textEncoder = new TextEncoder();
const callSession = { id: "", application: "m.call" };
describe("MatrixRTCSession", () => {
let client: MatrixClient;
let sess: MatrixRTCSession | undefined;
@@ -49,14 +51,33 @@ describe("MatrixRTCSession", () => {
it("creates a room-scoped session from room state", () => {
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", () => {
@@ -67,7 +88,7 @@ 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();
@@ -76,7 +97,7 @@ describe("MatrixRTCSession", () => {
it("ignores memberships events of members not in the room", () => {
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);
});
@@ -87,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);
});
@@ -129,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);
});
@@ -164,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);
});
@@ -172,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);
});
@@ -180,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);
});
});
@@ -211,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();
});
@@ -236,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",
@@ -256,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",
@@ -283,7 +288,7 @@ describe("MatrixRTCSession", () => {
client._unstable_updateDelayedEvent = jest.fn();
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
});
afterEach(async () => {
@@ -366,7 +371,7 @@ describe("MatrixRTCSession", () => {
describe("onMembershipsChanged", () => {
it("does not emit if no membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
@@ -377,7 +382,7 @@ describe("MatrixRTCSession", () => {
it("emits on membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
@@ -432,7 +437,7 @@ describe("MatrixRTCSession", () => {
client.encryptAndSendToDevice = sendToDeviceMock;
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
});
afterEach(async () => {
@@ -551,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) => {
@@ -600,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));
@@ -651,7 +656,7 @@ describe("MatrixRTCSession", () => {
const mockRoom = makeMockRoom([member1, member2]);
mockRoomState(mockRoom, [member1, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -696,7 +701,7 @@ describe("MatrixRTCSession", () => {
};
const mockRoom = makeMockRoom([member1, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -760,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(
@@ -850,7 +855,7 @@ 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
@@ -876,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);
@@ -917,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,
@@ -940,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", {
@@ -965,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", {
@@ -991,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", {
@@ -1042,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(
@@ -1101,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(
@@ -1145,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(
@@ -1168,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);
@@ -16,8 +16,9 @@ limitations under the License.
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
import { logger } from "../../../src/logger";
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
@@ -47,6 +48,21 @@ 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);
@@ -59,9 +75,75 @@ describe("MatrixRTCSessionManager", () => {
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));
});
});
+74 -34
View File
@@ -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;
@@ -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);
@@ -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];
@@ -495,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`
@@ -511,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
@@ -521,6 +558,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
@@ -552,6 +590,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
@@ -574,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);
@@ -595,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);
@@ -611,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);
@@ -638,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);
@@ -658,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(
@@ -693,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
@@ -726,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++) {
@@ -746,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++) {
@@ -757,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();
@@ -771,6 +810,7 @@ describe("MembershipManager", () => {
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive, unrecoverableError);
for (let retries = 0; retries < 7; retries++) {
@@ -788,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);
@@ -802,7 +842,7 @@ it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
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
@@ -448,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", () => {
@@ -471,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();
+17
View File
@@ -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", () => {
+8 -4
View File
@@ -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
View File
@@ -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();
});
});
+194 -7
View File
@@ -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", () => {
+89 -32
View File
@@ -47,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";
@@ -130,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 () => {
@@ -154,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 () => {
@@ -178,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 () => {
@@ -278,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");
@@ -294,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];
@@ -316,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
@@ -424,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];
@@ -1113,6 +1117,7 @@ describe("RustCrypto", () => {
it.each([
[undefined, undefined, null],
["Other", -1, EventShieldReason.UNKNOWN],
[
"Encrypted by an unverified user.",
RustSdkCryptoJs.ShieldStateCode.UnverifiedIdentity,
@@ -1139,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(() => {});
@@ -1548,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",
@@ -1793,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
@@ -1934,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
@@ -2302,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);
@@ -2359,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 */
+17
View File
@@ -41,6 +41,14 @@ 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}. */
@@ -49,6 +57,15 @@ 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 {
+23 -4
View File
@@ -223,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,
@@ -2371,7 +2371,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
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();
@@ -2398,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.
@@ -3764,11 +3778,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @returns An empty object.
*/
public invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
if (typeof opts != "object") {
opts = { reason: opts };
}
return this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
if (opts.shareEncryptedHistory) {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
}
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
}
/**
+13
View File
@@ -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
+20
View File
@@ -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} */
+24 -13
View File
@@ -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,
@@ -269,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";
}
@@ -299,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;
}
@@ -343,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
View File
@@ -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,
+11
View File
@@ -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;
}
+65 -12
View File
@@ -26,7 +26,7 @@ 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 { deepCompare, logDurationSync } from "../utils.ts";
import { type Statistics, type RTCNotificationType } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
@@ -74,6 +74,14 @@ export interface SessionConfig {
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.
@@ -86,7 +94,7 @@ 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;
@@ -254,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);
@@ -290,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;
}
@@ -323,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);
}
/**
@@ -373,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);
@@ -434,6 +486,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.sessionDescription,
this.logger,
);
@@ -642,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 ||
+10 -3
View File
@@ -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)!;
+22 -7
View File
@@ -26,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 {
@@ -101,7 +101,7 @@ export enum MembershipActionType {
// -> 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",
@@ -243,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,
@@ -294,6 +294,7 @@ export class MembershipManager
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
public readonly sessionDescription: SessionDescription,
parentLogger?: Logger,
) {
super();
@@ -624,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),
@@ -687,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 {
@@ -700,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" },
+20
View File
@@ -46,6 +46,11 @@ 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.
@@ -126,6 +131,8 @@ export class RTCEncryptionManager implements IEncryptionManager {
}
public join(joinConfig: EncryptionConfig | undefined): void {
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
this.logger?.info(`Joining room`);
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
@@ -174,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
@@ -196,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.
+6 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
/**
+5
View File
@@ -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;
+4 -6
View File
@@ -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.
+3 -6
View File
@@ -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);
}
+123 -12
View File
@@ -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";
@@ -37,6 +37,7 @@ 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
@@ -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 };
+40
View File
@@ -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);
}
+281 -223
View File
@@ -1162,9 +1162,9 @@
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.12.5":
version "7.27.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
version "7.28.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.2.tgz#2ae5a9d51cc583bd1f5673b3bb70d6d819682473"
integrity sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==
"@babel/template@^7.25.9", "@babel/template@^7.27.1", "@babel/template@^7.27.2":
version "7.27.2"
@@ -1307,25 +1307,25 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@emnapi/core@^1.4.3":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.4.tgz#76620673f3033626c6d79b1420d69f06a6bb153c"
integrity sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==
"@emnapi/core@^1.4.3", "@emnapi/core@^1.4.5":
version "1.4.5"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb"
integrity sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==
dependencies:
"@emnapi/wasi-threads" "1.0.3"
"@emnapi/wasi-threads" "1.0.4"
tslib "^2.4.0"
"@emnapi/runtime@^1.4.3":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.4.tgz#19a8f00719c51124e2d0fbf4aaad3fa7b0c92524"
integrity sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.4.5":
version "1.4.5"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.5.tgz#c67710d0661070f38418b6474584f159de38aba9"
integrity sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz#83fa228bde0e71668aad6db1af4937473d1d3ab1"
integrity sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==
"@emnapi/wasi-threads@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz#703fc094d969e273b1b71c292523b2f792862bf4"
integrity sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==
dependencies:
tslib "^2.4.0"
@@ -1389,15 +1389,15 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
"@gerrit0/mini-shiki@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@gerrit0/mini-shiki/-/mini-shiki-3.7.0.tgz#7ab9c6fabe0b78949ad383b4d0b4db4ad6cb8a68"
integrity sha512-7iY9wg4FWXmeoFJpUL2u+tsmh0d0jcEJHAIzVxl3TG4KL493JNnisdLAILZ77zcD+z3J0keEXZ+lFzUgzQzPDg==
"@gerrit0/mini-shiki@^3.9.0":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@gerrit0/mini-shiki/-/mini-shiki-3.9.2.tgz#de4cacde1d25e704720b717b387a54b11e6b4593"
integrity sha512-Tvsj+AOO4Z8xLRJK900WkyfxHsZQu+Zm1//oT1w443PO6RiYMoq/4NGOhaNuZoUMYsjKIAPVQ6eOFMddj6yphQ==
dependencies:
"@shikijs/engine-oniguruma" "^3.7.0"
"@shikijs/langs" "^3.7.0"
"@shikijs/themes" "^3.7.0"
"@shikijs/types" "^3.7.0"
"@shikijs/engine-oniguruma" "^3.9.2"
"@shikijs/langs" "^3.9.2"
"@shikijs/themes" "^3.9.2"
"@shikijs/types" "^3.9.2"
"@shikijs/vscode-textmate" "^10.0.2"
"@humanwhocodes/config-array@^0.13.0":
@@ -1707,10 +1707,10 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@matrix-org/matrix-sdk-crypto-wasm@^15.0.0":
version "15.0.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.0.0.tgz#5b29ca1c62f3aface9db06d7441d0a9ba2cd3439"
integrity sha512-tzBGf/jugrOw190Na77LljZIQMTSL6SAnZaATKMlb2j1XOfc5Q+bSJTb9ZWBR7TFs0d8K9spcwRHPc4S/7CMYw==
"@matrix-org/matrix-sdk-crypto-wasm@^15.1.0":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.1.0.tgz#653956f5f6daced55a9df3d2c1114eb2c017b528"
integrity sha512-ZsDdjn46J3+VxsDLmaSODuS+qtGZB/i3Cg9tWL1QPNjvAWzNaTHQ7glleByI2PKVBm83aklfuhGKT2MqE1ZsEA==
"@matrix-org/olm@3.2.15":
version "3.2.15"
@@ -1733,13 +1733,22 @@
integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==
"@napi-rs/wasm-runtime@^0.2.11":
version "0.2.11"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz#192c1610e1625048089ab4e35bc0649ce478500e"
integrity sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==
version "0.2.12"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==
dependencies:
"@emnapi/core" "^1.4.3"
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.9.0"
"@tybys/wasm-util" "^0.10.0"
"@napi-rs/wasm-runtime@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.1.tgz#006125f38a06d34000b014864cdbd810b24afdd1"
integrity sha512-KVlQ/jgywZpixGCKMNwxStmmbYEMyokZpCf2YuIChhfJA2uqfAKNEM8INz7zzTo55iEXfBhIIs3VqYyqzDLj8g==
dependencies:
"@emnapi/core" "^1.4.5"
"@emnapi/runtime" "^1.4.5"
"@tybys/wasm-util" "^0.10.0"
"@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3":
version "2.1.8-no-fsevents.3"
@@ -1774,72 +1783,102 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@oxc-resolver/binding-darwin-arm64@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.5.0.tgz#41469e68d2ffefa66f0d82d4dfc86121c47a8dd1"
integrity sha512-IQZZP6xjGvVNbXVPEwZeCDTkG7iajFsVZSaq7QwxuiJqkcE/GKd0GxGQMs6jjE72nrgSGVHQD/yws1PNzP9j5w==
"@oxc-resolver/binding-android-arm-eabi@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.6.0.tgz#669d31bebbbd60f4b3b3139caf6817d56361a241"
integrity sha512-UJTf5uZs919qavt9Btvbzkr3eaUu4d+FXBri8AB2BtOezriaTTUvArab2K9fdACQ4yFggTD5ews1l19V/6SW2Q==
"@oxc-resolver/binding-darwin-x64@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.5.0.tgz#b9576461bb755fed07b1463397a7d7d3fe854d4e"
integrity sha512-nY15IBY5NjOPKIDRJ2sSLr0GThFXz4J4lgIo4fmnXanJjeeXaM5aCOL3oIxT7RbONqyMki0lzMkbX7PWqW3/lw==
"@oxc-resolver/binding-android-arm64@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.6.0.tgz#44af2621b2a6887f6a27bc72d3a9cf7567ee2c1d"
integrity sha512-v17j1WLEAIlyc+6JOWPXcky7dkU3fN8nHTP8KSK05zkkBO0t28R3Q0udmNBiJtVSnw4EFB/fy/3Mu2ItpG6bVQ==
"@oxc-resolver/binding-freebsd-x64@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.5.0.tgz#599ebac48a5b56d2831197628062c4f97bc69189"
integrity sha512-WQibNtsWiJZ36Q2QKYSedN6c4xoZtLhU7UOFPGTMaw/J8eb+WYh5pfzTtZR9WGZQRoS3kj0E/9683Wuskz5mMQ==
"@oxc-resolver/binding-darwin-arm64@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.6.0.tgz#4afdd9b6bc5cadf75b735a38724575789c7b9210"
integrity sha512-ZrU+qd5AKe8s7PZDLCHY23UpbGn1RAkcNd4JYjOTnX22XEjSqLvyC6pCMngTyfgGVJ4zXFubBkRzt/k3xOjNlQ==
"@oxc-resolver/binding-linux-arm-gnueabihf@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.5.0.tgz#467bdc859289dc99b02de40a3f969527d8426e66"
integrity sha512-oZj20OTnjGn1qnBGYTjRXEMyd0inlw127s+DTC+Y0kdxoz5BUMqUhq5M9mZ1BH4c1qPlRto6shOFVrK4hNkhhA==
"@oxc-resolver/binding-darwin-x64@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.6.0.tgz#1c60dc800091f74f2b27f7ea5c933bb0fd7a2c84"
integrity sha512-qBIlX0X0RSxQHcXQnFpBGKxrDVtj7OdpWFGmrcR3NcndVjZ/wJRPST5uTTM83NfsHyuUeOi/vRZjmDrthvhnSQ==
"@oxc-resolver/binding-linux-arm64-gnu@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.5.0.tgz#d1084cc4e81a5630f70206402552912b62a5677f"
integrity sha512-zxFuO4Btd1BSFjuaO0mnIA9XRWP4FX3bTbVO9KjKvO8MX6Ig2+ZDNHpzzK2zkOunHGc4sJQm5oDTcMvww+hyag==
"@oxc-resolver/binding-freebsd-x64@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.6.0.tgz#da2ed708df6b9c78ded2b5729593808d74431fc8"
integrity sha512-tTyMlHHNhbkq/oEP/fM8hPZ6lqntHIz6EfOt577/lslrwxC5a/ii0lOOHjPuQtkurpyUBWYPs7Z17EgrZulc4Q==
"@oxc-resolver/binding-linux-arm64-musl@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.5.0.tgz#734470210be5c5c74b579d0b162285649d662699"
integrity sha512-mmDNrt2yyEnsPrmq3wzRsqEYM+cpVuv8itgYU++BNJrfzdJpK+OpvR3rPToTZSOZQt3iYLfqQ2hauIIraJnJGw==
"@oxc-resolver/binding-linux-arm-gnueabihf@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.6.0.tgz#f14b20d19af378c0ad1c074e8e23859c884ed001"
integrity sha512-tYinHy5k9/rujo21mG2jZckJJD7fsceNDl5HOl/eh5NPjSt2vXQv181PVKeITw3+3i+gI1d666w5EtgpiCegRA==
"@oxc-resolver/binding-linux-riscv64-gnu@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.5.0.tgz#3d780917faa6abc8666fbf5dbfa25213d37297a3"
integrity sha512-CxW3/uVUlSpIEJ3sLi5Q+lk7SVgQoxUKBTsMwpY2nFiCmtzHBOuwMMKES1Hk+w/Eirz09gDjoIrxkzg3ETDSGQ==
"@oxc-resolver/binding-linux-arm-musleabihf@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.6.0.tgz#abfa7f40e43125e11406882eb2f90ee9e47827be"
integrity sha512-aOlGlSiT9fBgSyiIWvSxbyzaBx3XrgCy6UJRrqBkIvMO9D7W90JmV0RsiLua4w43zJSSrfuQQWqmFCwgIib3Iw==
"@oxc-resolver/binding-linux-s390x-gnu@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.5.0.tgz#997898e23354a2f0c25c484232c440a4b0ac6f1b"
integrity sha512-RxfVqJnmO7uEGzpEgEzVb5Sxjy8NAYpQj+7JZZunxIyJiDK1KgOJqVJ0NZnRC1UAe/yyEpO82wQIOInaLqFBgA==
"@oxc-resolver/binding-linux-arm64-gnu@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.6.0.tgz#6582d483893cd03d00db76c21533abaf2ce0dad0"
integrity sha512-EZ/OuxZA9qQoAANBDb9V4krfYXU3MC+LZ9qY+cE0yMYMIxm7NT5AdR0OaRQqfa3tWIbina1VF7FaMR6rpKvmlA==
"@oxc-resolver/binding-linux-x64-gnu@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.5.0.tgz#329882d4c027c16ef1695180be7f3a4e253d9440"
integrity sha512-Ri36HuV91PVXFw1BpTisJOZ2x9dkfgsvrjVa3lPX+QS6QRvvcdogGjPTTqgg8WkzCh6RTzd7Lx9mCZQdw06HTQ==
"@oxc-resolver/binding-linux-arm64-musl@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.6.0.tgz#c3ac7767528aa0b195a145ffcae91ca0f61e0208"
integrity sha512-NpF7sID4NnPetpqDk2eOu6TPUt381Qlpos8nGDcSkAluqSsSGFOPfETEB5VbJeqNVQbepEQX9mOxZygFpW0+nA==
"@oxc-resolver/binding-linux-x64-musl@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.5.0.tgz#322638dc4e9b2080d591ce05871fa03648c78f52"
integrity sha512-xskd2J4Jnfuze2jYKiZx4J+PY4hJ5Z0MuVh8JPNvu/FY1+SAdRei9S95dhc399Nw6eINre7xOrsugr11td3k4Q==
"@oxc-resolver/binding-linux-ppc64-gnu@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.6.0.tgz#691b391bedf7b0fb69d177fef9898ba88743ed70"
integrity sha512-Sqn9Ha4rxCCpjpfkFi9f9y9phsaBnseaKw+JqHgBQoNMToe+/20A1jwIu9OX+484UuLpduM+wLydgngjnoi7Dg==
"@oxc-resolver/binding-wasm32-wasi@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.5.0.tgz#c92cdd476056d0a98f2f98bce9891260ce2aaef2"
integrity sha512-ZAHTs0MzHUlHAqKffvutprVhO7OlENWisu1fW/bVY6r+TPxsl25Q0lzbOUhrxTIJ9f0Sl5meCI2fkPeovZA7bQ==
"@oxc-resolver/binding-linux-riscv64-gnu@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.6.0.tgz#292abc194dc14d58aad1216107c6d3a413da88a4"
integrity sha512-eFoNcPhImp1FLAQf5U3Nlph4WNWEsdWohSThSTtKPrX+jhPZiVsj3iBC9gjaRwq2Ez4QhP1x7/PSL6mtKnS6rw==
"@oxc-resolver/binding-linux-riscv64-musl@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.6.0.tgz#11c5b20ddea1762237eb2dc819160cb5fd926cf3"
integrity sha512-WQw3CT10aJg7SIc/X1QPrh6lTx2wOLg5IaCu/+Mqlxf1nZBEW3+tV/+y3PzXG0MCRhq7FDTiHaW8MBVAwBineQ==
"@oxc-resolver/binding-linux-s390x-gnu@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.6.0.tgz#c261af364724fff7ea9cc39546923de5e24fab89"
integrity sha512-p5qcPr/EtGJ2PpeeArL3ifZU/YljWLypeu38+e19z2dyPv8Aoby8tjM+D1VTI8+suMwTkseyove/uu6zIUiqRw==
"@oxc-resolver/binding-linux-x64-gnu@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.6.0.tgz#5894eeacfc116415d09a97b3aa06a9e9010fac59"
integrity sha512-/9M/ieoY5v54k3UjtF9Vw43WQ4bBfed+qRL1uIpFbZcO2qi5aXwVMYnjSd/BoaRtDs5JFV9iOjzHwpw0zdOYZA==
"@oxc-resolver/binding-linux-x64-musl@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.6.0.tgz#582078c4c85adf4bdc36d6ad88f031ef22600b69"
integrity sha512-HMtWWHTU7zbwceTFZPAPMMhhWR1nNO2OR60r6i55VprCMvttTWPQl7uLP0AUtAPoU9B/2GqP48rzOuaaKhHnYw==
"@oxc-resolver/binding-wasm32-wasi@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.6.0.tgz#702b995ea41b822405a311f39ce93356ad2ca234"
integrity sha512-rDAwr2oqmnG/6LSZJwvO3Bmt/RC3/Q6myyaUmg3P7GhZDyFPrWJONB7NFhPwU2Q4JIpA73ST4LBdhzmGxMTmrw==
dependencies:
"@napi-rs/wasm-runtime" "^0.2.11"
"@napi-rs/wasm-runtime" "^1.0.0"
"@oxc-resolver/binding-win32-arm64-msvc@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.5.0.tgz#d0c2dea3113d0c48918d5efb4d1a6710e51528b7"
integrity sha512-4/3RJnkrKo7EbBdWAYsSHZEjgZ8TYYAt/HrHDo5yy/5dUvxvPoetNtAudCiYKNgJOlFLzmzIXyn713MljEy6RA==
"@oxc-resolver/binding-win32-arm64-msvc@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.6.0.tgz#b7b636ec1059b52e025f39a597bf3ac53d63153e"
integrity sha512-COzy8weljZo2lObWl6ZzW6ypDx1v1rtLdnt7JPjTUARikK1gMzlz9kouQhCtCegNFILx2L2oWw7714fnchqujw==
"@oxc-resolver/binding-win32-x64-msvc@11.5.0":
version "11.5.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.5.0.tgz#111adc60e84ebd5648a3511cd394bcc307b867ae"
integrity sha512-poXrxQLJA770Xy3gAS9mrC/dp6GatYdvNlwCWwjL6lzBNToEK66kx3tgqIaOYIqtjJDKYR58P3jWgmwJyJxEAQ==
"@oxc-resolver/binding-win32-ia32-msvc@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.6.0.tgz#97c2289f8b5212da6fdf2fac43e25a4b0bc6bb5f"
integrity sha512-p2tMRdi91CovjLBApDPD/uEy1/5r7U6iVkfagLYDytgvj6nJ1EAxLUdXbhoe6//50IvDC/5I51nGCdxmOUiXlQ==
"@oxc-resolver/binding-win32-x64-msvc@11.6.0":
version "11.6.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.6.0.tgz#aa94a1da4d4380ee78fb923d9ecba35fc4df8ef6"
integrity sha512-p6b9q5TACd/y39kDK2HENXqd4lThoVrTkxdvizqd5/VwyHcoSd0cDcIEhHpxvfjc83VsODCBgB/zcjp//TlaqA==
"@peculiar/asn1-schema@^2.3.8":
version "2.3.8"
@@ -1878,32 +1917,32 @@
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
"@shikijs/engine-oniguruma@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.7.0.tgz#77afe066ed245ae9c3e790ca22344195e974ed54"
integrity sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==
"@shikijs/engine-oniguruma@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.9.2.tgz#f6e0e2d1b7211b98353de0753aaa6447ed57e5ab"
integrity sha512-Vn/w5oyQ6TUgTVDIC/BrpXwIlfK6V6kGWDVVz2eRkF2v13YoENUvaNwxMsQU/t6oCuZKzqp9vqtEtEzKl9VegA==
dependencies:
"@shikijs/types" "3.7.0"
"@shikijs/types" "3.9.2"
"@shikijs/vscode-textmate" "^10.0.2"
"@shikijs/langs@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.7.0.tgz#c201d0218e9f3a74d92bd3f53167f0fb897c5b6e"
integrity sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==
"@shikijs/langs@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.9.2.tgz#6de3b2e62c9b46e0e81a54396a98bc43aa7aedad"
integrity sha512-X1Q6wRRQXY7HqAuX3I8WjMscjeGjqXCg/Sve7J2GWFORXkSrXud23UECqTBIdCSNKJioFtmUGJQNKtlMMZMn0w==
dependencies:
"@shikijs/types" "3.7.0"
"@shikijs/types" "3.9.2"
"@shikijs/themes@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.7.0.tgz#e4fb08ca56826dd3b0a48a555140cb090ce0a639"
integrity sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==
"@shikijs/themes@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.9.2.tgz#e6b603b21e40c8e40e9723f7ad1bcca18dedd838"
integrity sha512-6z5lBPBMRfLyyEsgf6uJDHPa6NAGVzFJqH4EAZ+03+7sedYir2yJBRu2uPZOKmj43GyhVHWHvyduLDAwJQfDjA==
dependencies:
"@shikijs/types" "3.7.0"
"@shikijs/types" "3.9.2"
"@shikijs/types@3.7.0", "@shikijs/types@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.7.0.tgz#265641647708663ec8a18a9fab29449076da5a17"
integrity sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==
"@shikijs/types@3.9.2", "@shikijs/types@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.9.2.tgz#a29da422d0a4d853a56156abfafc3ad2b3bb6316"
integrity sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw==
dependencies:
"@shikijs/vscode-textmate" "^10.0.2"
"@types/hast" "^3.0.4"
@@ -1938,16 +1977,16 @@
"@sinonjs/commons" "^3.0.0"
"@stylistic/eslint-plugin@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.1.0.tgz#98769a3e6fc68d92deba20538341b0ea7923b3ce"
integrity sha512-TJRJul4u/lmry5N/kyCU+7RWWOk0wyXN+BncRlDYBqpLFnzXkd7QGVfN7KewarFIXv0IX0jSF/Ksu7aHWEDeuw==
version "5.2.2"
resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.2.2.tgz#a1cb24f17263e1dcb2d5c94069dc3ae7af1eb9ce"
integrity sha512-bE2DUjruqXlHYP3Q2Gpqiuj2bHq7/88FnuaS0FjeGGLCy+X6a07bGVuwtiOYnPSLHR6jmx5Bwdv+j7l8H+G97A==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/types" "^8.34.1"
"@typescript-eslint/types" "^8.37.0"
eslint-visitor-keys "^4.2.1"
espree "^10.4.0"
estraverse "^5.3.0"
picomatch "^4.0.2"
picomatch "^4.0.3"
"@tootallnate/once@2":
version "2.0.0"
@@ -1974,10 +2013,10 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
"@tybys/wasm-util@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==
"@tybys/wasm-util@^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369"
integrity sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==
dependencies:
tslib "^2.4.0"
@@ -2104,9 +2143,9 @@
undici-types "~5.26.4"
"@types/node@18":
version "18.19.115"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.115.tgz#cd94caf14472021b4443c99bcd7aac6bb5c4f672"
integrity sha512-kNrFiTgG4a9JAn1LMQeLOv3MvXIPokzXziohMrMsvpYgLpdEt/mMiVYc4sGKtDfyxM5gIDF4VgrPRyCw4fHOYg==
version "18.19.121"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.121.tgz#c50d353ea2d1fb1261a8bbd0bf2850306f5af2b3"
integrity sha512-bHOrbyztmyYIi4f1R0s17QsPs1uyyYnGcXeZoGEd227oZjry0q6XQBQxd82X1I57zEfwO8h9Xo+Kl5gX1d9MwQ==
dependencies:
undici-types "~5.26.4"
@@ -2153,38 +2192,38 @@
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@^8.0.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz#880ce277f8a30ccf539ec027acac157088f131ae"
integrity sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz#c9afec1866ee1a6ea3d768b5f8e92201efbbba06"
integrity sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.36.0"
"@typescript-eslint/type-utils" "8.36.0"
"@typescript-eslint/utils" "8.36.0"
"@typescript-eslint/visitor-keys" "8.36.0"
"@typescript-eslint/scope-manager" "8.39.0"
"@typescript-eslint/type-utils" "8.39.0"
"@typescript-eslint/utils" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@^8.0.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.36.0.tgz#003007fe2030013936b6634b9cf52c457d36ed42"
integrity sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.39.0.tgz#c4b895d7a47f4cd5ee6ee77ea30e61d58b802008"
integrity sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==
dependencies:
"@typescript-eslint/scope-manager" "8.36.0"
"@typescript-eslint/types" "8.36.0"
"@typescript-eslint/typescript-estree" "8.36.0"
"@typescript-eslint/visitor-keys" "8.36.0"
"@typescript-eslint/scope-manager" "8.39.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.36.0.tgz#0c4acdcbe56476a43cdabaac1f08819424a379fd"
integrity sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==
"@typescript-eslint/project-service@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.39.0.tgz#71cb29c3f8139f99a905b8705127bffc2ae84759"
integrity sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.36.0"
"@typescript-eslint/types" "^8.36.0"
"@typescript-eslint/tsconfig-utils" "^8.39.0"
"@typescript-eslint/types" "^8.39.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.21.0":
@@ -2195,26 +2234,27 @@
"@typescript-eslint/types" "8.21.0"
"@typescript-eslint/visitor-keys" "8.21.0"
"@typescript-eslint/scope-manager@8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz#23e4196ed07d7ea3737a584fbebc9a79c3835168"
integrity sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==
"@typescript-eslint/scope-manager@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz#ba4bf6d8257bbc172c298febf16bc22df4856570"
integrity sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==
dependencies:
"@typescript-eslint/types" "8.36.0"
"@typescript-eslint/visitor-keys" "8.36.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
"@typescript-eslint/tsconfig-utils@8.36.0", "@typescript-eslint/tsconfig-utils@^8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz#63ef8a20ae9b5754c6ceacbe87b2fe1aab12ba13"
integrity sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==
"@typescript-eslint/tsconfig-utils@8.39.0", "@typescript-eslint/tsconfig-utils@^8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz#b2e87fef41a3067c570533b722f6af47be213f13"
integrity sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==
"@typescript-eslint/type-utils@8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz#16b092c2cbbb5549f6a4df1382a481586850502f"
integrity sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==
"@typescript-eslint/type-utils@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz#310ec781ae5e7bb0f5940bfd652573587f22786b"
integrity sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==
dependencies:
"@typescript-eslint/typescript-estree" "8.36.0"
"@typescript-eslint/utils" "8.36.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/utils" "8.39.0"
debug "^4.3.4"
ts-api-utils "^2.1.0"
@@ -2223,10 +2263,15 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.21.0.tgz#58f30aec8db8212fd886835dc5969cdf47cb29f5"
integrity sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==
"@typescript-eslint/types@8.36.0", "@typescript-eslint/types@^8.34.1", "@typescript-eslint/types@^8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.36.0.tgz#d3d184adc2899e2912c13b17c1590486ef37c7ac"
integrity sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==
"@typescript-eslint/types@8.39.0", "@typescript-eslint/types@^8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.39.0.tgz#80f010b7169d434a91cd0529d70a528dbc9c99c6"
integrity sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==
"@typescript-eslint/types@^8.37.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.38.0.tgz#297351c994976b93c82ac0f0e206c8143aa82529"
integrity sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==
"@typescript-eslint/typescript-estree@8.21.0":
version "8.21.0"
@@ -2242,15 +2287,15 @@
semver "^7.6.0"
ts-api-utils "^2.0.0"
"@typescript-eslint/typescript-estree@8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz#344857fa79f71715369554a3cbb6b4ff8695a7bc"
integrity sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==
"@typescript-eslint/typescript-estree@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz#b9477a5c47a0feceffe91adf553ad9a3cd4cb3d6"
integrity sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==
dependencies:
"@typescript-eslint/project-service" "8.36.0"
"@typescript-eslint/tsconfig-utils" "8.36.0"
"@typescript-eslint/types" "8.36.0"
"@typescript-eslint/visitor-keys" "8.36.0"
"@typescript-eslint/project-service" "8.39.0"
"@typescript-eslint/tsconfig-utils" "8.39.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -2258,15 +2303,15 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.36.0.tgz#2c9af5292f14e0aa4b0e9c7ac0406afafb299acf"
integrity sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==
"@typescript-eslint/utils@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.39.0.tgz#dfea42f3c7ec85f9f3e994ff0bba8f3b2f09e220"
integrity sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.36.0"
"@typescript-eslint/types" "8.36.0"
"@typescript-eslint/typescript-estree" "8.36.0"
"@typescript-eslint/scope-manager" "8.39.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.21.0"
@@ -2286,12 +2331,12 @@
"@typescript-eslint/types" "8.21.0"
eslint-visitor-keys "^4.2.0"
"@typescript-eslint/visitor-keys@8.36.0":
version "8.36.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz#7dc6ba4dd037979eb3a3bdd2093aa3604bb73674"
integrity sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==
"@typescript-eslint/visitor-keys@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz#5d619a6e810cdd3fd1913632719cbccab08bf875"
integrity sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==
dependencies:
"@typescript-eslint/types" "8.36.0"
"@typescript-eslint/types" "8.39.0"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.2.0":
@@ -2860,9 +2905,9 @@ chalk@^4.0.0:
supports-color "^7.1.0"
chalk@^5.4.1:
version "5.4.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8"
integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==
version "5.5.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.5.0.tgz#67ada1df5ca809dc84c9b819d76418ddcf128428"
integrity sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==
char-regex@^1.0.2:
version "1.0.2"
@@ -3431,9 +3476,9 @@ eslint-config-google@^0.14.0:
integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
eslint-config-prettier@^10.0.0:
version "10.1.5"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782"
integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==
version "10.1.8"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-import-context@^0.1.8:
version "0.1.9"
@@ -5156,9 +5201,9 @@ kleur@^3.0.3:
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
knip@^5.0.0:
version "5.61.3"
resolved "https://registry.yarnpkg.com/knip/-/knip-5.61.3.tgz#3382e68f8b6fda11681bc31ccaeaa55cb2eb9786"
integrity sha512-8iSz8i8ufIjuUwUKzEwye7ROAW0RzCze7T770bUiz0PKL+SSwbs4RS32fjMztLwcOzSsNPlXdUAeqmkdzXxJ1Q==
version "5.62.0"
resolved "https://registry.yarnpkg.com/knip/-/knip-5.62.0.tgz#c86ba03edfb7139715c15a2b8d24f54b9075585b"
integrity sha512-hfTUVzmrMNMT1khlZfAYmBABeehwWUUrizLQoLamoRhSFkygsGIXWx31kaWKBgEaIVL77T3Uz7IxGvSw+CvQ6A==
dependencies:
"@nodelib/fs.walk" "^1.2.3"
fast-glob "^3.3.3"
@@ -5205,25 +5250,25 @@ linkify-it@^5.0.0:
uc.micro "^2.0.0"
lint-staged@^16.0.0:
version "16.1.2"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.1.2.tgz#8cb84daa844f39c7a9790dd2c0caa327125ef059"
integrity sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==
version "16.1.4"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.1.4.tgz#f6ff3f23b87369da6184f165abc56553b70bb988"
integrity sha512-xy7rnzQrhTVGKMpv6+bmIA3C0yET31x8OhKBYfvGo0/byeZ6E0BjGARrir3Kg/RhhYHutpsi01+2J5IpfVoueA==
dependencies:
chalk "^5.4.1"
commander "^14.0.0"
debug "^4.4.1"
lilconfig "^3.1.3"
listr2 "^8.3.3"
listr2 "^9.0.1"
micromatch "^4.0.8"
nano-spawn "^1.0.2"
pidtree "^0.6.0"
string-argv "^0.3.2"
yaml "^2.8.0"
listr2@^8.3.3:
version "8.3.3"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-8.3.3.tgz#815fc8f738260ff220981bf9e866b3e11e8121bf"
integrity sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==
listr2@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.1.tgz#3cad12d81d998f8024621d9b35c969dba5da4103"
integrity sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==
dependencies:
cli-truncate "^4.0.0"
colorette "^2.0.20"
@@ -5602,23 +5647,31 @@ optionator@^0.9.3:
word-wrap "^1.2.5"
oxc-resolver@^11.1.0:
version "11.5.0"
resolved "https://registry.yarnpkg.com/oxc-resolver/-/oxc-resolver-11.5.0.tgz#6f6098d32c8e30d3b3d0dfa81746e70fee252a30"
integrity sha512-lG/AiquYQP/4OOXaKmlPvLeCOxtlZ535489H3yk4euimwnJXIViQus2Y9Mc4c45wFQ0UYM1rFduiJ8+RGjUtTQ==
version "11.6.0"
resolved "https://registry.yarnpkg.com/oxc-resolver/-/oxc-resolver-11.6.0.tgz#c904ba479430b5b9f557a7fbc9adaff3fcabd048"
integrity sha512-Yj3Wy+zLljtFL8ByKOljaPhiXjJWVe875p5MHaT5VAHoEmzeg1BuswM8s/E7ErpJ3s0fsXJfUYJE4v1bl7N65g==
dependencies:
napi-postinstall "^0.3.0"
optionalDependencies:
"@oxc-resolver/binding-darwin-arm64" "11.5.0"
"@oxc-resolver/binding-darwin-x64" "11.5.0"
"@oxc-resolver/binding-freebsd-x64" "11.5.0"
"@oxc-resolver/binding-linux-arm-gnueabihf" "11.5.0"
"@oxc-resolver/binding-linux-arm64-gnu" "11.5.0"
"@oxc-resolver/binding-linux-arm64-musl" "11.5.0"
"@oxc-resolver/binding-linux-riscv64-gnu" "11.5.0"
"@oxc-resolver/binding-linux-s390x-gnu" "11.5.0"
"@oxc-resolver/binding-linux-x64-gnu" "11.5.0"
"@oxc-resolver/binding-linux-x64-musl" "11.5.0"
"@oxc-resolver/binding-wasm32-wasi" "11.5.0"
"@oxc-resolver/binding-win32-arm64-msvc" "11.5.0"
"@oxc-resolver/binding-win32-x64-msvc" "11.5.0"
"@oxc-resolver/binding-android-arm-eabi" "11.6.0"
"@oxc-resolver/binding-android-arm64" "11.6.0"
"@oxc-resolver/binding-darwin-arm64" "11.6.0"
"@oxc-resolver/binding-darwin-x64" "11.6.0"
"@oxc-resolver/binding-freebsd-x64" "11.6.0"
"@oxc-resolver/binding-linux-arm-gnueabihf" "11.6.0"
"@oxc-resolver/binding-linux-arm-musleabihf" "11.6.0"
"@oxc-resolver/binding-linux-arm64-gnu" "11.6.0"
"@oxc-resolver/binding-linux-arm64-musl" "11.6.0"
"@oxc-resolver/binding-linux-ppc64-gnu" "11.6.0"
"@oxc-resolver/binding-linux-riscv64-gnu" "11.6.0"
"@oxc-resolver/binding-linux-riscv64-musl" "11.6.0"
"@oxc-resolver/binding-linux-s390x-gnu" "11.6.0"
"@oxc-resolver/binding-linux-x64-gnu" "11.6.0"
"@oxc-resolver/binding-linux-x64-musl" "11.6.0"
"@oxc-resolver/binding-wasm32-wasi" "11.6.0"
"@oxc-resolver/binding-win32-arm64-msvc" "11.6.0"
"@oxc-resolver/binding-win32-ia32-msvc" "11.6.0"
"@oxc-resolver/binding-win32-x64-msvc" "11.6.0"
p-limit@^2.2.0:
version "2.3.0"
@@ -5745,7 +5798,12 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
picomatch@^4.0.1, picomatch@^4.0.2:
picomatch@^4.0.1, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
picomatch@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
@@ -6650,9 +6708,9 @@ typedoc-plugin-coverage@^4.0.0:
integrity sha512-P1QBR5GJSfW3fDrpz4Vkd8z8lzWaBYjaHebRLk0u2Uga0oSFlPaqrCyiHzItBXxZX28aMlNlZwrUnsLgUgqA7g==
typedoc-plugin-mdn-links@^5.0.0:
version "5.0.2"
resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-5.0.2.tgz#c9be740e7a0114a6e236c5e21c54a3d78e5cac0d"
integrity sha512-Bd3lsVWPSpDkn6NGZyPHpcK088PUvH4SRq4RD97OjA6l8PQA3yOnJhGACtjmIDdcenRTgWUosH+55ANZhx/wkw==
version "5.0.7"
resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-5.0.7.tgz#cb889648575bdf6cbf2eeccf8d8ab40f36b6487c"
integrity sha512-F4hSmW4wGn562EuF8UQWmutQfWlvSoP+14QJ6UQcNViGrBCQ9NLqze5LuhYc0DLxuQnWIzUnCShpuy2Zo5LtfQ==
typedoc-plugin-missing-exports@^4.0.0:
version "4.0.0"
@@ -6660,11 +6718,11 @@ typedoc-plugin-missing-exports@^4.0.0:
integrity sha512-Z4ei+853xppDEhcqzyeyRs4+R0kUuKQWnMK1EtSTEd5LFkgkdW5Bdn8vfo/rsCGbYVJxOWU99fxgM1mROw5Fug==
typedoc@^0.28.1:
version "0.28.7"
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.28.7.tgz#30453a9517b49e53f06e08954ef3dd106381447d"
integrity sha512-lpz0Oxl6aidFkmS90VQDQjk/Qf2iw0IUvFqirdONBdj7jPSN9mGXhy66BcGNDxx5ZMyKKiBVAREvPEzT6Uxipw==
version "0.28.9"
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.28.9.tgz#9af272796c6e68077a044149d1f7626b37de9693"
integrity sha512-aw45vwtwOl3QkUAmWCnLV9QW1xY+FSX2zzlit4MAfE99wX+Jij4ycnpbAWgBXsRrxmfs9LaYktg/eX5Bpthd3g==
dependencies:
"@gerrit0/mini-shiki" "^3.7.0"
"@gerrit0/mini-shiki" "^3.9.0"
lunr "^2.3.9"
markdown-it "^14.1.0"
minimatch "^9.0.5"
@@ -7006,9 +7064,9 @@ yallist@^3.0.2:
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
yaml@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.0.tgz#15f8c9866211bdc2d3781a0890e44d4fa1a5fff6"
integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==
version "2.8.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79"
integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==
yargs-parser@^21.1.1:
version "21.1.1"
@@ -7039,9 +7097,9 @@ yocto-queue@^0.1.0:
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod-validation-error@^3.0.3:
version "3.5.2"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.5.2.tgz#5af463c1acd4662e6b2610a75260931dbcb43a56"
integrity sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw==
version "3.5.3"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.5.3.tgz#85ba33290200d8db9f043621e284f40dddefb7e5"
integrity sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==
zod@^3.22.4:
version "3.25.76"