Compare commits

..

140 Commits

Author SHA1 Message Date
RiotRobot ef97df8ed0 v38.3.0 2025-09-23 12:12:22 +00:00
RiotRobot 0ce751c462 v38.3.0-rc.0 2025-09-16 14:09:05 +00:00
RiotRobot 2706873948 Merge branch 'master' into develop 2025-09-16 11:40:18 +00:00
RiotRobot 4a9006aea6 v38.2.0 2025-09-16 11:39:28 +00:00
Michael Telatynski 43c72d5bf5 Merge commit from fork
* Validate room upgrade relationships in MatrixClient::getJoinedRooms

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 12:36:14 +01:00
Andy Balaam e551b92a07 Update matrix-sdk-crypto-wasm to 15.2.0 (#4991)
* Update matrix-sdk-crypto-wasm to 15.2.0

Most relevant changes:

-   History sharing: improve efficiency of building key bundle
    ([matrix-rust-sdk#5513](https://github.com/matrix-org/matrix-rust-sdk/issues/5513))

* Work around matrix-rust-sdk#5643

Modify the message content coming from Rust API to include the missing
property `msgtype: m.key.verification.request`
2025-09-11 14:53:13 +00:00
Richard van der Hoff 32f51e852b History sharing: do /keys/query before checking for key bundle (#4992)
* History sharing: do `/keys/query` before checking for key bundle

The next release of matrix-sdk-crypto-wasm will check that the device that sent
us the key bundle data was correctly cross-signed by its owner, which means we
need to have the owner's cross-signing keys before we check if we have the
bundle.

This replicates a change made in the Rust SDK, at https://github.com/matrix-org/matrix-rust-sdk/pull/5510/files#diff-9f89fa75c4eb3743ae674be1bb90f75169bd815a259917799c71b8a546449d51R133-R140.

* fix unit test

* Comment
2025-09-11 10:38:14 +00:00
RiotRobot 82aa04d894 Merge branch 'master' into develop 2025-09-09 16:30:39 +00:00
RiotRobot ff89c9ec42 v38.1.0 2025-09-09 16:29:55 +00:00
Svajūnas Budrys ccd825fb39 Remove knock state on join (#4977)
Signed-off-by: Svajunas Budrys <svajunas.budrys.sb@gmail.com>
2025-09-04 09:31:26 +00:00
renovate[bot] b313eb5912 Update dependency @types/sdp-transform to v2.15.0 (#4987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 09:06:51 +00:00
renovate[bot] 2b12675675 Update typescript-eslint monorepo to v8.41.0 (#4989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 17:14:06 +00:00
renovate[bot] 246788b874 Update shogo82148/actions-upload-release-asset digest to e6cd457 (#4985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 17:13:57 +00:00
renovate[bot] b0b80401aa Update actions/upload-pages-artifact action to v4 (#4990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:39:36 +00:00
renovate[bot] 6a0164f37f Update dependency knip to v5.63.0 (#4988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:30:21 +00:00
renovate[bot] f963d61bcb Update typedoc (#4986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:29:33 +00:00
RiotRobot b32619ad24 v38.1.0-rc.0 2025-09-02 12:53:40 +00:00
Tulir Asokan 3d3c3ba55f Fix m.topic format (#4984)
* Fix m.topic format

Fixes #4902

Signed-off-by: Tulir Asokan <tulir@maunium.net>

* Update tests

Signed-off-by: Tulir Asokan <tulir@maunium.net>

* Fix formatting

* Re-add temporary support for invalid form

Signed-off-by: Tulir Asokan <tulir@maunium.net>

---------

Signed-off-by: Tulir Asokan <tulir@maunium.net>
2025-09-02 12:14:08 +00:00
Timo d62c658a72 Remove custom org.matrix.msc4075.rtc.notification.parent relation type (#4979)
* Remove custom `org.matrix.msc4075.rtc.notification.parent` relation type

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

* fix test

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

* Fix test post rebase

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-09-02 08:36:26 +00:00
David Baker bdc4a69023 Fix stable-suffixed MSC4133 support (#4983)
* Fix stable-suffixed MSC4133 support

It looked for the ".stable" suffixed feature to work out what URL to use but not to see whether the server supported it.

This will only be relevant until the next spec release but may as well fix it.

See also https://github.com/element-hq/element-web/pull/30649

* Fix awaiting
2025-09-01 15:34:01 +01:00
Bas Nijholt ab892420b5 Fix thread edit aggregation race condition (#4980)
* test(thread): add regression tests for edit-race; ensure reaction aggregation idempotence

- Edit race: add failing test when `Replace` aggregated pre-init
- Reaction: ensure pre-init aggregation and dedup on replay
- Strengthen assertions for ordering and content

* fix(thread): apply edits after init; keep reactions pre-init; remove redundant aggregation
- Defer Replace aggregation until thread initialised and event is in timeline
- Aggregate Annotation pre-init to preserve reaction summaries
- Rely on EventTimelineSet to aggregate post-insert
- Fixes: element-hq/element-web#30617

* style: run prettier; docs: clarify reaction pre-init comment about counts
2025-08-28 09:03:33 +00:00
RiotRobot ed607c48b0 Merge branch 'master' into develop 2025-08-27 13:32:25 +00:00
RiotRobot a8d75b81e5 v38.0.0 2025-08-27 13:31:35 +00:00
Timo 2f1d654f14 MatrixRTC: Add RTC decline event (#4978)
* Add decline event

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

* add `client.sendRtcDecline`.

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

* remove `decline.reason` field.

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 15:21:40 +00:00
Timo c4c7f94514 Make a MatrixRTCSession emit once the RTCNotification is sent (#4976)
* MatrixRTCSession emits once the rtc notification is sent.

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

* update correct type description

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

* Add test

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

* fix imports

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 15:16:02 +00:00
David Baker 1fac06e223 Use hydra semantics for unknown room versions (#4957)
This inverts the check for whether to use hydra semantics to only NOT use
it for known, old room versions and use hydra for everything else, so
rooms with versions we don't know about will use hydra semantics.

This will cause any rooms using old/experiental versions unknown to
the js-sdk to break, but will mean that wehn the next room version
comes out, we'll use hydra for it which is, of course, not a given,
but is way more likely than going back to the old semantics.

The mobile Element clients currently hardcode hydra versions (ie.
as it is without this change, but we expect them to make this same
change soon after the hydra release.

We do NOT expect this to land with the hydra release, but target it for
the release after.

Reverts 1e5054a8ff87f83b0875916aa16f435853bf165a from https://github.com/matrix-org/matrix-js-sdk/pull/4937

See https://github.com/element-hq/element-meta/issues/2921 for public
discussion.
2025-08-26 10:49:44 +00:00
RiotRobot 77c118084b v38.0.0-rc.1 2025-08-21 14:16:23 +00:00
ElementRobot 097bfe451a Release tranche of breaking changes (#4963) (#4975)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-08-21 15:08:48 +01:00
Michael Telatynski b80d0091d2 Release tranche of breaking changes (#4963)
* Remove deprecated `IJoinRoomOpts.syncRoom` option (#4914)

This option is non-functional, and was deprecated in
https://github.com/matrix-org/matrix-js-sdk/pull/4913. Remove it altogether.

* Remove support for `onlyData != true` (#4939)

* Remove deprecated fields, methods, utilities (#4959)

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-08-21 13:24:36 +00:00
Timo 3a33c658bb Expose the StatusChanged event through the RTCSession (#4974)
* Expose the StatusChanged event through the RTCSession

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

* add membershipManagerStatus public get field

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

* add probably left as a getter

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

* add tests for coverage

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 16:48:56 +00:00
Timo 81e42b9531 Add probablyLeft event to the MatrixRTCSession (#4962)
* Add probablyLeft emission to the MatrixRTCSession

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

* add docstring

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

* Review: add additional test + fix pending promises issue.

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

* review: `Pick` only a subset of membership manager events

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

* reveiw: update probablyLeft logic to be more straight forward

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

* fix test

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

* make test not wait for 5s

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

* review

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

* fix linter (rebase)

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

* fix import

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 12:05:07 +00:00
renovate[bot] 7f7ecd060d Update dependency lint-staged to v16.1.5 (#4969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 15:06:35 +00:00
renovate[bot] 6126ee125a Update typedoc (#4970)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-08-19 14:27:08 +00:00
renovate[bot] 78f718ff82 Update typescript-eslint monorepo to v8.39.1 (#4971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:27:09 +00:00
renovate[bot] c1c1be0c5d Update actions/download-artifact action to v5 (#4973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:26:16 +00:00
renovate[bot] 1952eaa1ff Update actions/checkout action to v5 (#4972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:26:08 +00:00
renovate[bot] 8851f8b07c Update dependency @types/node to v18.19.123 (#4968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:51 +00:00
renovate[bot] 15eafe34b3 Update babel monorepo to v7.28.3 (#4966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:25 +00:00
renovate[bot] f0d48236fa Update dependency @stylistic/eslint-plugin to v5.2.3 (#4967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:23 +00:00
renovate[bot] dde9d48726 Update shogo82148/actions-upload-release-asset digest to 62365f2 (#4965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:06 +00:00
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
RiotRobot aa7a709e3f v37.12.0 2025-07-29 12:58:45 +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
RiotRobot 4fbb9f92a7 v37.12.0-rc.0 2025-07-22 13:23:33 +00:00
Richard van der Hoff 1a58ce4649 Simplify declaration of ResponseType (#4926)
Currently, this is looking for a `json` property on `IHttpOpts`. There is no
such property, so that part of the declaration is completely redundant, and we
may as well remove it.

I looked into making it check `IRequestOpts`, which *does* have a `json`
property, but couldn't make it work.

Also add some docs, while we're there.
2025-07-22 10:07:56 +00:00
dependabot[bot] 32509d1fd1 Bump form-data from 4.0.0 to 4.0.4 (#4928)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 09:03:52 +00:00
Timo 11c9e39e5a Custom abort timeout logic for restarting delayed events that is compatible with the widget api (#4927)
* add custom local timout + add delay to 0 for normal local timeout.

* consider retry limits for new custom error

* mock the AbortError so we can reuse `actionUpdateFromErrors`

* update comment
2025-07-21 18:42:21 +00:00
toriningen 0ce944f3da Fix deep import incompatibility (#4924) (#4925) 2025-07-21 08:08:24 +00:00
Richard van der Hoff 38e04c8fb0 Give MatrixClient.invite an options param (#4919) 2025-07-19 07:29:12 +00:00
Valere Fedronic 1fcbc6ebeb RTCEncryptionManager: Joiner key rotation grace period (#4911)
* RTCEncryptionManager: Joiner key rotation grace period

* Test to clarify useKeyDelay and keyRotationGracePeriodMs interference

* make test more configurable

* rename delayRolloutTimeMillis to useKeyDelay same as config option

* rename skipRotationGracePeriod to keyRotationGracePeriodMs

* clarify that oldMemberships is not used by RTCEncryptionManager

* improve doc

* cleanup test

* more comment in test

* comment additions

* cleanup runOnlyPendingTimers

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-07-18 15:16:45 +00:00
Robin aa79236ce2 Allow sending notification events when starting a call (#4826)
* Make it easier to mock call memberships for specific user IDs

* Allow sending notification events when starting a call

* rename notify -> notification

* replace `joining` concept with `ownMembership`

* introduce new `m.rtc.notification` event alongside `m.call.notify`

* send new notification event alongside the deprecated one

* Test for new notification event type

* update relation string to match msc

* review

* fix doc errors

* fix tests + format

* remove anything decline related

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-07-18 12:42:57 +00:00
RiotRobot f8f1bf3837 Merge branch 'master' into develop 2025-07-15 14:45:35 +00:00
RiotRobot 946cf4f359 v37.11.0 2025-07-15 14:44:53 +00:00
Valere Fedronic c077201f2a EncryptionManager: un-deprecating EncryptionManager.getEncryptionKeys (#4912)
* EncryptionManager: should be able to re-emit keys

* fix typo in test file name

* review unneeded cast

* remove bad comment
2025-07-15 07:47:03 +00:00
Richard van der Hoff 53f2ad41d6 Deprecate non-functional IJoinRoomOpts.syncRoom (#4913)
I don't know when this last did something, but it's been a while.
2025-07-10 08:40:17 +00:00
Richard van der Hoff be15a709c6 Tests: gate logging behind DEBUG env var (#4903)
* Add `DebugLogger` type for logging matrix-js-sdk to `debug`

* unit tests for DebugLogger

* Use `DebugLogger` in some tests

* Use `DebugLogger` in rust-crypto.spec

* test-utils: silence some logging
2025-07-10 06:15:00 +00:00
renovate[bot] 090b8079db Update all non-major dependencies (#4905)
* Update all non-major dependencies

* Prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-09 09:39:57 +00:00
Richard van der Hoff 06a1e1a88a More incorrect logger use (#4904)
A couple of places where we were still using the legacy logger
2025-07-08 15:37:17 +00:00
renovate[bot] 024b62bba0 Update dependency @stylistic/eslint-plugin to v5 (#4910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:36:47 +00:00
renovate[bot] 6bfb911cf7 Update dependency @types/node to v18.19.115 (#4906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:58:11 +00:00
renovate[bot] ad5da7cfa1 Update babel monorepo to v7.28.0 (#4909)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:38:22 +00:00
renovate[bot] e55bd1e14c Update typescript-eslint monorepo to v8.35.1 (#4908)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:38:09 +00:00
renovate[bot] 119e859741 Update dependency typedoc to v0.28.7 (#4907)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:37:59 +00:00
RiotRobot b7cce93edc v37.11.0-rc.0 2025-07-08 13:21:56 +00:00
Valere Fedronic e5c8c20a34 MatrixRTC: Refactor | Introduce a new Encryption manager (used with experimental to device transport) (#4799)
* refactor: New encryption manager BasicEncryptionManager for todevice

fixup: bad do not commit

* fix: ToDevice transport not setting the sent_ts

* test: BasicEncryptionManager add statistics tests

* code review

* feat: Encryption manager just reshare on new joiner

* refactor: Rename BasicEncryptionManger to RTCEncryptionManager

* fixup: RTC experimental todevice should use new encryption mgr

* fixup: use proper logger hierarchy

* fixup: RTC rollout first key asap even if no members to send to

* fixup: RTC add test for first key use

* fixup! emitting outbound key before anyone registered

* fix: quick patch for transport switch, need test

* test: RTC encryption manager, add test for transport switch

* post rebase fix

* Remove bad corepack commit

* review: cleaning, renaming

* review: cleaning and renaming

* stop using root logger in favor of a parent logger

* post merge fix broken test

* remove corepack again

* fix reverted changes after a merge

* review: Properly deprecate getEncryptionKeys

* review: rename ensureMediaKeyDistribution to ensureKeyDistribution

* review: use OutdatedKeyFilter instead of KeyBuffer
2025-07-08 12:43:16 +00:00
Neil Johnson 137379b7b7 Update README.md
style
2025-07-04 17:43:29 +01:00
Neil Johnson 4981efa54b Update README to make Element sponsorship explicit (#4901) 2025-07-04 17:41:35 +01:00
Richard van der Hoff 6e9740d787 Use client logger in more places (crypto code) (#4900)
* Use client logger for `RustBackupManager`

* use client logger in `CrossSigningIdentity`

* use client logger in `OutgoingRequestProcessor`

* RoomEncryptor: use correct logger for logDuration

use the logger for this specific event, rather than the more general one for the room

* Use client logger in `RoomEncryptor`
2025-07-04 07:35:42 +00:00
Richard van der Hoff 70257e0ab4 use client logger in MatrixRTCSessionManager (#4898) 2025-07-03 15:56:51 +00:00
Valere Fedronic 9baba151c6 Move ClientEvent docs to the event itself instead of the type map (#4894)
* doc: Add ClientEvent doc on the event enum

* Cleanup: Remove useless doc on EventHandlerMap

* quick format
2025-07-03 12:26:10 +00:00
Richard van der Hoff b4672e26ec Use client logger in more places (core code) (#4899)
* Use client logger for sync

Use the logger attached to the MatrixClient when writing log messages out of
the sync api. This helps figure out what's going on when multiple clients are
running in the same JS environment.

* Use client logger for to-device message queue

* Use client logger in `PushProcessor.rewriteDefaultRules`

* use client logger in `ServerCapabilities`

* Mark global `logger` as deprecated
2025-07-03 09:58:01 +01:00
Richard van der Hoff 940d358b0e Test: stop loading Olm into global namespace (#4895)
* Test: stop loading Olm into global namespace

Now that the js-sdk no longer relies on libolm, there is no need to populate
`globalThis.Olm`. Remove the code that did so (or relied on it being done).

* fix lint
2025-07-02 14:48:45 +00:00
Valere Fedronic 161c12f5d5 crypto: Add new ClientEvent.ReceivedToDeviceMessage with proper OlmEncryptionInfo support (#4891)
* crypto: Add new ClientEvent.ReceivedToDeviceMessage

refactor rename ProcessedToDeviceEvent to ReceivedToDeviceEvent

* fix: Restore legacy isEncrypted() for to-device messages

* Update test for new preprocessToDeviceMessages API

* quick fix on doc

* quick update docs and renaming

* review: Better doc and names for OlmEncryptionInfo

* review: Remove IToDeviceMessage alias and only keep IToDeviceEvent

* review: improve comments of processToDeviceMessages

* review: pass up encrypted event when no crypto callbacks

* review: use single payload for ReceivedToDeviceMessage

* fix linter

* review: minor comment update
2025-07-02 08:02:23 +00:00
RiotRobot de659d6431 Merge branch 'master' into develop 2025-07-01 14:54:29 +00:00
RiotRobot b095aa600d v37.10.0 2025-07-01 14:53:40 +00:00
David Baker 18ea8befdc Specify typeroot (#4892)
This prevents tsc from picking up random types from parent directories
such as in situations like an element-web layered build, and generally
seems like good hygiene as we don't want to pick up random types from
whatever directory we happen to be checked out into.
2025-07-01 11:31:28 +00:00
Timo 4f9ca2c697 Remove LegacyMembershipManager (#4862)
* Remove `LegacyMemberhsipManager`

* remove tests from rtc session
Those tests were only run with the legacy membership manager and are redundant with the memberhsip manager test spec.

* fix tests

* dont use non existing TestManager anymore

* remove fails for legacy

* fix another test
2025-06-26 12:19:51 +00:00
renovate[bot] 57a4dc8841 Update dependency @types/node to v18.19.112 (#4886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 12:34:38 +00:00
renovate[bot] 841c02e56d Update shogo82148/actions-upload-release-asset digest to 610b198 (#4885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 11:04:54 +00:00
renovate[bot] 6ece4c3c16 Update all non-major dependencies (#4888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 16:44:15 +00:00
renovate[bot] 5a3c07f91d Update typescript-eslint monorepo to v8.34.1 (#4887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:54:48 +00:00
renovate[bot] 3ed4b3ed50 Update mheap/github-action-required-labels digest to 8afbe8a (#4884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:20:47 +00:00
renovate[bot] 9ca9bd9baf Update guibranco/github-status-action-v2 digest to 741ea90 (#4883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:02:53 +00:00
RiotRobot 0265f6ea2d v37.10.0-rc.0 2025-06-24 12:40:23 +00:00
Richard van der Hoff 8f597f0f87 Update matrix-sdk-crypto-wasm to 15.0.0 (#4882)
For js-sdk users, this includes the following:

    -   Send stable identifier `sender_device_keys` for MSC4147 (Including device keys with Olm-encrypted events).
        ([#4964](https://github.com/matrix-org/matrix-rust-sdk/pull/4964))

    -   Check the `sender_device_keys` field on _all_ incoming Olm-encrypted to-device messages and ignore any to-device messages which include the field but whose data is invalid (as per [MSC4147](https://github.com/matrix-org/matrix-spec-proposals/pull/4147)).
        ([#4922](https://github.com/matrix-org/matrix-rust-sdk/pull/4922))

    -   Fix bug which caused room keys to be unnecessarily rotated on every send in the presence of blacklisted/withheld devices in the room.
        ([#4954](https://github.com/matrix-org/matrix-rust-sdk/pull/4954))

    -   Fix [matrix-rust-sdk#2729](https://github.com/matrix-org/matrix-rust-sdk/issues/2729) which in rare cases can cause room key oversharing.
        ([#4975](https://github.com/matrix-org/matrix-rust-sdk/pull/4975))
2025-06-23 17:35:37 +00:00
Michael Telatynski 67df2a53c4 Remove redundant git-revision.txt file (#4881)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-20 08:08:30 +00:00
Patrick Cloke 8367277894 Allow customizing the IndexedDB database prefix used by Rust crypto. (#4878)
* Allow customizing the IndexedDB database prefix used by Rust crypto.

Related to #3974

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>

* Rename argument

---------

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>
2025-06-18 09:07:35 +00:00
RiotRobot 4efb27354f Merge branch 'master' into develop 2025-06-17 13:09:40 +00:00
RiotRobot 28bc90563e v37.9.0 2025-06-17 13:09:06 +00:00
Will Hunt f9be1bf57a Add prepare script (#4877)
* Add prepare script

* Remove 'prepack' script.
2025-06-17 10:18:19 +00:00
Richard van der Hoff adaf921623 Remove @matrix-org/olm from dependency list (#4876)
Olm is needed at build time to run the tests, but is no longer needed at
runtime.
2025-06-12 09:28:39 +00:00
Travis Ralston cdece6cb9f Redact on ban: Client implementation (#4867)
* First pass implementation

* fix naming/docs

* apply lint

* Add test for existing behaviour

* Add happy path tests

* Fix bug identified by tests

* ... and this is why we add negative tests too

* Add some sanity tests

* Apply linter
2025-06-10 14:28:02 +00:00
renovate[bot] d438e25f87 Update dependency @stylistic/eslint-plugin to v4.4.1 (#4873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 14:16:20 +00:00
renovate[bot] 73d8f4384d Update all non-major dependencies (#4870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:32 +00:00
renovate[bot] b0cb6aa724 Update babel monorepo (#4871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:20 +00:00
renovate[bot] dfc26f8aa1 Update definitelyTyped (#4872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:11 +00:00
renovate[bot] 286fb8f752 Update typescript-eslint monorepo to v8.33.1 (#4874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:55:42 +00:00
RiotRobot 921de6807d v37.9.0-rc.0 2025-06-10 13:16:01 +00:00
RiotRobot d4e7b949e7 Merge branch 'master' into develop 2025-06-10 12:00:47 +00:00
renovate[bot] 1e80538cfb Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 (#4868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 11:30:04 +00:00
Michael Telatynski 73cbcfa4ee Ensure we send spec-compliant filter strings by stripping out null values (#4865)
* Ensure we send spec-compliant filter strings by stripping out null values

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-09 08:32:42 +00:00
Richard van der Hoff 99972ce0a9 Factor out common code for processing to-device events (#4863)
`sliding-sync-sdk.ts` and `sync.ts` both have copies of this code, and it's
redundant.
2025-06-05 08:57:50 +00:00
Timo 44399f6017 Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) (#4861)
* add test run helper to allow running long tests in vs code

* deprecate IDeferred (as its associated defer method is also deprecated and its just a type rename to PromiseWithResolvers)

* Improve docs and readability of MembershipManager.spec.ts

* Intoduce test for a race condition which results in a state where the state event and the hasMemberStateEvent variable diverge

* fix room state and membership manager state diverging. See:
https://github.com/element-hq/element-call-rageshakes/issues/10609
https://github.com/element-hq/element-call-rageshakes/issues/10594
https://github.com/element-hq/element-call-rageshakes/issues/9902

* logging, docstings and variable name improvements

* review

* review pending timers
2025-06-04 10:44:12 +00:00
RiotRobot c387f30e5c Merge branch 'master' into develop 2025-06-03 14:50:36 +00:00
rsb-tbg 12a9875c46 Include extraParams in all HTTP requests (#4860)
* attaching queryParams from client config in getUrl

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

* changed client queryParams to QueryDict for consistency and now merging both sets of params in getUrl if one or both exist

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

* added tests

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

---------

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>
2025-05-30 09:09:21 +00:00
renovate[bot] 74f5efc4ef Update all non-major dependencies (#4859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 08:01:03 +00:00
renovate[bot] 43d47982ed Update dependency @types/node to v18.19.105 (#4858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 07:47:37 +00:00
renovate[bot] 94fb489952 Update dependency @stylistic/eslint-plugin to v4.4.0 (#4855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 15:36:54 +00:00
renovate[bot] 75ae05e5eb Update typescript-eslint monorepo to v8.33.0 (#4856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:45:13 +00:00
renovate[bot] 9058b79c39 Update all non-major dependencies (#4854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:44:33 +00:00
renovate[bot] 671dd2ca40 Update dependency typedoc to v0.28.5 (#4852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:44:13 +00:00
renovate[bot] 34f35393ff Update dependency @types/node to v18.19.103 (#4851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:43:50 +00:00
renovate[bot] e206a12902 Update babel monorepo to v7.27.3 (#4850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:43:26 +00:00
126 changed files with 6924 additions and 2990 deletions
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ env.VERSION }}.tar.gz.asc
@@ -29,13 +29,13 @@ runs:
- name: Upload asset signatures
if: inputs.gpg-fingerprint
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-24.04
steps:
- uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
if: github.event_name != 'merge_group'
with:
labels: |
@@ -16,7 +16,7 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: staging
fetch-depth: 0
@@ -37,7 +37,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+2 -2
View File
@@ -18,14 +18,14 @@ jobs:
merge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+2 -2
View File
@@ -73,7 +73,7 @@ jobs:
draft: true
latest: true
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: staging
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
@@ -81,7 +81,7 @@ jobs:
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: staging
+3 -3
View File
@@ -44,7 +44,7 @@ jobs:
repo:
- element-hq/element-web
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: ${{ matrix.repo }}
ref: staging
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: 🔧 Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -88,7 +88,7 @@ jobs:
run: yarn gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: _docs
+5 -5
View File
@@ -27,7 +27,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -36,14 +36,14 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -51,7 +51,7 @@ jobs:
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -87,7 +87,7 @@ jobs:
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+7 -7
View File
@@ -14,7 +14,7 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -31,7 +31,7 @@ jobs:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -48,7 +48,7 @@ jobs:
name: "Node.js example"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -83,7 +83,7 @@ jobs:
name: "Workflow Lint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -100,7 +100,7 @@ jobs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -125,7 +125,7 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -143,7 +143,7 @@ jobs:
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
repository: element-hq/element-web
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Node
id: setupNode
@@ -116,7 +116,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1 -2
View File
@@ -13,8 +13,7 @@ out
/dist
/lib
# version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt
# tarball created by `npm pack` / `yarn pack`
/matrix-js-sdk-*.tgz
.vscode
+123
View File
@@ -1,3 +1,126 @@
Changes in [38.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.3.0) (2025-09-23)
==================================================================================================
## 🐛 Bug Fixes
* Remove knocked room when membership changes to join ([#4977](https://github.com/matrix-org/matrix-js-sdk/pull/4977)). Contributed by @svajunas-budrys.
Changes in [38.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.2.0) (2025-09-16)
==================================================================================================
Fix [CVE-2025-59160](https://www.cve.org/CVERecord?id=CVE-2025-59160) / [GHSA-mp7c-m3rh-r56v](https://github.com/matrix-org/matrix-js-sdk/security/advisories/GHSA-mp7c-m3rh-r56v)
Changes in [38.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.1.0) (2025-09-09)
==================================================================================================
## ✨ Features
* Remove custom `org.matrix.msc4075.rtc.notification.parent` relation type ([#4979](https://github.com/matrix-org/matrix-js-sdk/pull/4979)). Contributed by @toger5.
* MatrixRTC: Add RTC decline event ([#4978](https://github.com/matrix-org/matrix-js-sdk/pull/4978)). Contributed by @toger5.
* Make a MatrixRTCSession emit once the RTCNotification is sent ([#4976](https://github.com/matrix-org/matrix-js-sdk/pull/4976)). Contributed by @toger5.
* Use hydra semantics for unknown room versions ([#4957](https://github.com/matrix-org/matrix-js-sdk/pull/4957)). Contributed by @dbkr.
* Expose the StatusChanged event through the RTCSession ([#4974](https://github.com/matrix-org/matrix-js-sdk/pull/4974)). Contributed by @toger5.
* Add `probablyLeft` event to the MatrixRTCSession ([#4962](https://github.com/matrix-org/matrix-js-sdk/pull/4962)). Contributed by @toger5.
## 🐛 Bug Fixes
* Fix `m.topic` format ([#4984](https://github.com/matrix-org/matrix-js-sdk/pull/4984)). Contributed by @tulir.
* Fix stable-suffixed MSC4133 support ([#4983](https://github.com/matrix-org/matrix-js-sdk/pull/4983)). Contributed by @dbkr.
* Fix thread edit aggregation race condition ([#4980](https://github.com/matrix-org/matrix-js-sdk/pull/4980)). Contributed by @basnijholt.
Changes in [38.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.0.0) (2025-08-27)
==================================================================================================
## 🚨 BREAKING CHANGES
* Release tranche of breaking changes ([#4975](https://github.com/matrix-org/matrix-js-sdk/pull/4975)).
* Remove support for FetchHttpApi `onlyData = false`
* Remove deprecated `IJoinRoomOpts.syncRoom`
* Remove deprecated methods which are unsupported in rust crypto
* Remove deprecated getAuthIssuer method
* Remove deprecated beginKeyVerification method
* Remove deprecated isEncryptedDisabledForUnverifiedDevices getter
* Remove deprecated UndecryptableToDeviceEvent MatrixClient emit
* Remove deprecated defer utility method
* Remove deprecated UIAResponse dummy type
* Remove deprecated MatrixRTCSession MembershipConfig fields
* Remove deprecated findVerificationRequestDMInProgress and storeSessionBackupPrivateKey methods in favour of overloads
## ✨ Features
* Allow multiple rtc sessions per room (with different sessionDescriptions) ([#4945](https://github.com/matrix-org/matrix-js-sdk/pull/4945)). Contributed by @toger5.
* Add support for login\_hint in authorization url generation ([#4943](https://github.com/matrix-org/matrix-js-sdk/pull/4943)). Contributed by @odelcroi.
* Only process MatrixRTC sessions associated with calls for `callMembershipsForRoom` ([#4960](https://github.com/matrix-org/matrix-js-sdk/pull/4960)). Contributed by @fkwp.
Changes in [37.13.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.13.0) (2025-08-11)
====================================================================================================
This release supports new v12 Matrix rooms and consequently has a breaking change, removing powerLevelNorm from the RoomMember object as this can't be supported with infinite power levels. Apps should use the non-normalised `powerLevel` instead.
## 🚨 BREAKING CHANGES
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
## ✨ Features
* [Backport staging] Support v12 rooms in maySendEvent ([#4956](https://github.com/matrix-org/matrix-js-sdk/pull/4956)). Contributed by @RiotRobot.
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
* Experimental support for sharing encrypted history on invite ([#4920](https://github.com/matrix-org/matrix-js-sdk/pull/4920)). Contributed by @richvdh.
* Use the logger associated with MatrixClient in rust sdk ([#4918](https://github.com/matrix-org/matrix-js-sdk/pull/4918)). Contributed by @richvdh.
* Update to matrix-sdk-crypto-wasm 15.1.0, and add new `ShieldStateCode.MismatchedSender` ([#4916](https://github.com/matrix-org/matrix-js-sdk/pull/4916)). Contributed by @richvdh.
## 🐛 Bug Fixes
* Fix unknown/broken state in the RTC Membership Manager causing unnecassary error logging. ([#4944](https://github.com/matrix-org/matrix-js-sdk/pull/4944)). Contributed by @toger5.
Changes in [37.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.12.0) (2025-07-29)
====================================================================================================
## 🦖 Deprecations
* Deprecate non-functional `IJoinRoomOpts.syncRoom` ([#4913](https://github.com/matrix-org/matrix-js-sdk/pull/4913)). Contributed by @richvdh.
## ✨ Features
* Custom abort timeout logic for restarting delayed events that is compatible with the widget api ([#4927](https://github.com/matrix-org/matrix-js-sdk/pull/4927)). Contributed by @toger5.
* Allow sending notification events when starting a call ([#4826](https://github.com/matrix-org/matrix-js-sdk/pull/4826)). Contributed by @robintown.
## 🐛 Bug Fixes
* Fix deep import incompatibility (#4924) ([#4925](https://github.com/matrix-org/matrix-js-sdk/pull/4925)). Contributed by @toriningen.
* Fix more incorrect logger use ([#4904](https://github.com/matrix-org/matrix-js-sdk/pull/4904)). Contributed by @richvdh.
Changes in [37.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.11.0) (2025-07-15)
====================================================================================================
## ✨ Features
* Update README to make Element sponsorship explicit ([#4901](https://github.com/matrix-org/matrix-js-sdk/pull/4901)). Contributed by @neilisfragile.
* Use client logger in more places (crypto code) ([#4900](https://github.com/matrix-org/matrix-js-sdk/pull/4900)). Contributed by @richvdh.
* Use client logger in `MatrixRTCSessionManager` ([#4898](https://github.com/matrix-org/matrix-js-sdk/pull/4898)). Contributed by @richvdh.
* Use client logger in more places (core code) ([#4899](https://github.com/matrix-org/matrix-js-sdk/pull/4899)). Contributed by @richvdh.
* crypto: Add new `ClientEvent.ReceivedToDeviceMessage` with proper `OlmEncryptionInfo` support ([#4891](https://github.com/matrix-org/matrix-js-sdk/pull/4891)). Contributed by @BillCarsonFr.
Changes in [37.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.10.0) (2025-07-01)
====================================================================================================
## ✨ Features
* Update matrix-sdk-crypto-wasm to `15.0.0` ([#4882](https://github.com/matrix-org/matrix-js-sdk/pull/4882)). Contributed by @richvdh.
* Allow customizing the IndexedDB database prefix used by Rust crypto. ([#4878](https://github.com/matrix-org/matrix-js-sdk/pull/4878)). Contributed by @clokep.
* Remove `@matrix-org/olm` from dependency list ([#4876](https://github.com/matrix-org/matrix-js-sdk/pull/4876)). Contributed by @richvdh.
* Redact on ban: Client implementation ([#4867](https://github.com/matrix-org/matrix-js-sdk/pull/4867)). Contributed by @turt2live.
Changes in [37.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.9.0) (2025-06-17)
==================================================================================================
## 🐛 Bug Fixes
* Ensure we send spec-compliant filter strings by stripping out null values ([#4865](https://github.com/matrix-org/matrix-js-sdk/pull/4865)). Contributed by @t3chguy.
* Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) ([#4861](https://github.com/matrix-org/matrix-js-sdk/pull/4861)). Contributed by @toger5.
* Include extraParams in all HTTP requests ([#4860](https://github.com/matrix-org/matrix-js-sdk/pull/4860)). Contributed by @rsb-tbg.
Changes in [37.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.8.0) (2025-06-10)
==================================================================================================
## 🐛 Bug Fixes
+16
View File
@@ -11,6 +11,22 @@
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
browser or in Node.js.
---
<picture>
<source srcset="contrib/element-logo-light.png" media="(prefers-color-scheme: dark)">
<source srcset="contrib/element-logo-dark.png" media="(prefers-color-scheme: light)">
<img src="contrib/element-logo-fallback.png" alt="Element logo">
</picture>
<br>
Development and maintenance is proudly sponsored by [Element](https://element.io). Element uses the SDK in their flagship [web](https://github.com/element-hq/element-web) and [desktop](https://github.com/element-hq/element-desktop) clients.
The SDK is also the basis for multiple Matrix projects and we welcome contributions from all.
---
#### Minimum Matrix server version: v1.1
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
-3
View File
@@ -119,7 +119,6 @@ Unless otherwise specified, the following applies to all code:
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
1. Note that an explicit type is optional if not expected to be used outside of the function call,
unlike in this example:
@@ -153,7 +152,6 @@ Unless otherwise specified, the following applies to all code:
28. Export only what can be reused.
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
of truly optional parameters.
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
takes an argument that is more often not required than required. An example where the
`?` operator is inappropriate is when taking a room ID: typically the caller should
@@ -272,7 +270,6 @@ Unless otherwise specified, the following applies to all code:
interfaces have a habit of becoming more complex over time.
3. Inside a function, there is no need to comment every line, but consider:
- before a particular multiline section of code within the function, give an overview of what it does,
to make it easier for a reader to follow the flow through the function as a whole.
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+7 -8
View File
@@ -1,16 +1,15 @@
{
"name": "matrix-js-sdk",
"version": "37.8.0",
"version": "38.3.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"prepack": "yarn build",
"prepare": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"clean": "rimraf lib",
"build": "yarn build:dev",
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
"build": "yarn clean && yarn build:compile && yarn build:types",
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"gendoc": "typedoc",
@@ -50,8 +49,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^14.2.0",
"@matrix-org/olm": "3.2.15",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.2.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -81,8 +79,9 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@matrix-org/olm": "3.2.15",
"@peculiar/webcrypto": "^1.4.5",
"@stylistic/eslint-plugin": "^4.0.0",
"@stylistic/eslint-plugin": "^5.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/jest": "^29.0.0",
@@ -116,7 +115,7 @@
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.5.3",
"prettier": "3.6.2",
"rimraf": "^6.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.28.1",
-3
View File
@@ -19,9 +19,6 @@ limitations under the License.
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
/* eslint-disable jest/no-standalone-expect */
// load olm before the sdk if possible
import "./olm-loader";
import MockHttpBackend from "matrix-mock-request";
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
+12 -10
View File
@@ -17,9 +17,10 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import debug from "debug";
import { syncPromise } from "../../test-utils/test-utils";
import { type AuthDict, createClient, type MatrixClient } from "../../../src";
import { type AuthDict, createClient, DebugLogger, type MatrixClient } from "../../../src";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts";
import { type CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
@@ -91,6 +92,7 @@ describe("cross-signing", () => {
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
cryptoCallbacks: createCryptoCallbacks(),
logger: new DebugLogger(debug(`matrix-js-sdk:cross-signing`)),
});
syncResponder = new SyncResponder(homeserverUrl);
@@ -135,9 +137,9 @@ describe("cross-signing", () => {
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
// check the cross-signing keys upload
expect(fetchMock.called("upload-keys")).toBeTruthy();
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
// check that the cross-signing keys have been uploaded
expect(fetchMock.called("upload-cross-signing-keys")).toBeTruthy();
const [, keysOpts] = fetchMock.lastCall("upload-cross-signing-keys")!;
const keysBody = JSON.parse(keysOpts!.body as string);
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
// there should be a key of each type
@@ -223,9 +225,6 @@ describe("cross-signing", () => {
await aliceClient.startClient();
await syncPromise(aliceClient);
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
@@ -418,15 +417,18 @@ describe("cross-signing", () => {
return new Promise<any>((resolve) => {
fetchMock.post(
{
url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"),
name: "upload-keys",
url: new URL(
"/_matrix/client/v3/keys/device_signing/upload",
aliceClient.getHomeserverUrl(),
).toString(),
name: "upload-cross-signing-keys",
},
(url, options) => {
const content = JSON.parse(options.body as string);
resolve(content);
return {};
},
// Override the routes define in `mockSetupCrossSigningRequests`
// Override the route defined in E2EKeyReceiver
{ overwriteRoutes: true },
);
});
+5 -12
View File
@@ -19,9 +19,9 @@ import anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import Olm from "@matrix-org/olm";
import type FetchMock from "fetch-mock";
import type Olm from "@matrix-org/olm";
import * as testUtils from "../../test-utils/test-utils";
import {
emitPromise,
@@ -124,7 +124,6 @@ async function expectSendRoomKey(
recipientOlmAccount: Olm.Account,
recipientOlmSession: Olm.Session | null = null,
): Promise<Olm.InboundGroupSession> {
const Olm = globalThis.Olm;
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
function onSendRoomKey(content: any): Olm.InboundGroupSession {
@@ -207,14 +206,6 @@ async function expectSendMegolmMessage(
}
describe("crypto", () => {
if (!globalThis.Olm) {
// currently we use libolm to implement the crypto in the tests, so need it to be present.
logger.warn("not running megolm tests: Olm not present");
return;
}
const Olm = globalThis.Olm;
let testOlmAccount = {} as unknown as Olm.Account;
let testSenderKey = "";
@@ -1500,8 +1491,10 @@ describe("crypto", () => {
expect(ev.decryptionFailureReason).toEqual(expectedErrorCode);
// `isEncryptedDisabledForUnverifiedDevices` should be true for `m.unverified` and false for other errors.
expect(ev.isEncryptedDisabledForUnverifiedDevices).toEqual(withheldCode === "m.unverified");
// `decryptionFailureReason` should be `MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE` for `m.unverified`
expect(
ev.decryptionFailureReason === DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
).toEqual(withheldCode === "m.unverified");
});
},
);
+3 -3
View File
@@ -16,8 +16,9 @@ limitations under the License.
import "fake-indexeddb/auto";
import fetchMock from "fetch-mock-jest";
import debug from "debug";
import { ClientEvent, createClient, type MatrixClient, MatrixEvent } from "../../../src";
import { ClientEvent, createClient, DebugLogger, type MatrixClient, MatrixEvent } from "../../../src";
import { CryptoEvent } from "../../../src/crypto-api/index";
import { type RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { type AddSecretStorageKeyOpts } from "../../../src/secret-storage";
@@ -38,6 +39,7 @@ describe("Device dehydration", () => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
},
logger: new DebugLogger(debug(`matrix-js-sdk:dehydration`)),
});
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
@@ -179,8 +181,6 @@ async function initializeSecretStorage(
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
const accountData: Map<string, object> = new Map();
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
const name = url.split("/").pop()!;
+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 {};
},
);
});
}
+4 -4
View File
@@ -93,15 +93,15 @@ export function bootstrapCrossSigningTestOlmAccount(
deviceId: string,
keyBackupInfo: KeyBackupInfo[] = [],
): Partial<IDownloadKeyResult> {
const olmAliceMSK = new globalThis.Olm.PkSigning();
const olmAliceMSK = new Olm.PkSigning();
const masterPrivkey = olmAliceMSK.generate_seed();
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
const olmAliceUSK = new globalThis.Olm.PkSigning();
const olmAliceUSK = new Olm.PkSigning();
const userPrivkey = olmAliceUSK.generate_seed();
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
const olmAliceSSK = new globalThis.Olm.PkSigning();
const olmAliceSSK = new Olm.PkSigning();
const sskPrivkey = olmAliceSSK.generate_seed();
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
@@ -189,7 +189,7 @@ export async function createOlmSession(
const otkId = Object.keys(keys)[0];
const otk = keys[otkId];
const session = new globalThis.Olm.Session();
const session = new Olm.Session();
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
return session;
}
+35
View File
@@ -67,6 +67,23 @@ describe("MatrixClient.initRustCrypto", () => {
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
});
it("should create the indexed db with a custom prefix", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
// should have an indexed db now
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
expect(databaseNames).toEqual(expect.arrayContaining(["my-prefix::matrix-sdk-crypto"]));
});
it("should create the meta db if given a storageKey", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
@@ -475,4 +492,22 @@ describe("MatrixClient.clearStores", () => {
await matrixClient.clearStores();
// No error thrown in clearStores
});
it("should clear the indexeddbs with a custom prefix", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
expect(await indexedDB.databases()).toHaveLength(1);
await matrixClient.stopClient();
await matrixClient.clearStores({ cryptoDatabasePrefix: "my-prefix" });
expect(await indexedDB.databases()).toHaveLength(0);
});
});
+121 -3
View File
@@ -17,13 +17,22 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import Olm from "@matrix-org/olm";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { createClient, type MatrixClient } from "../../../src";
import {
ClientEvent,
createClient,
type IToDeviceEvent,
type MatrixClient,
type MatrixEvent,
type ReceivedToDeviceMessage,
} from "../../../src";
import * as testData from "../../test-utils/test-data";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { SyncResponder } from "../../test-utils/SyncResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys } from "./olm-utils.ts";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -43,6 +52,8 @@ describe("to-device-messages", () => {
/** an object which intercepts `/keys/query` requests on the test homeserver */
let e2eKeyResponder: E2EKeyResponder;
let e2eKeyReceiver: E2EKeyReceiver;
let syncResponder: SyncResponder;
beforeEach(
async () => {
@@ -59,8 +70,8 @@ describe("to-device-messages", () => {
});
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
new E2EKeyReceiver(homeserverUrl);
const syncResponder = new SyncResponder(homeserverUrl);
e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
// add bob as known user
syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
@@ -149,4 +160,111 @@ describe("to-device-messages", () => {
// for future: check that bob's device can decrypt the ciphertext?
});
});
describe("receive to-device-messages", () => {
it("Should receive decrypted to-device message via ClientEvent", async () => {
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
await Olm.init();
const testOlmAccount = new Olm.Account();
testOlmAccount.create();
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
e2eKeyResponder.addDeviceKeys(testDeviceKeys);
await aliceClient.startClient();
await syncPromise(aliceClient);
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
const p2pSession = await establishOlmSession(aliceClient, e2eKeyReceiver, syncResponder, testOlmAccount);
const toDeviceEvent = encryptOlmEvent({
sender: "@bob:xyz",
senderKey: testDeviceKeys.keys[`curve25519:DEVICE_ID`],
senderSigningKey: testDeviceKeys.keys[`ed25519:DEVICE_ID`],
p2pSession: p2pSession,
recipient: aliceClient.getUserId()!,
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
plaincontent: {
body: "foo",
},
plaintype: "m.test.type",
});
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
processedToDeviceResolver.resolve(payload);
});
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
oldToDeviceResolver.resolve(event);
});
expect(toDeviceEvent.type).toBe("m.room.encrypted");
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
await syncPromise(aliceClient);
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
expect(message.type).toBe("m.test.type");
expect(message.content["body"]).toBe("foo");
expect(encryptionInfo).not.toBeNull();
expect(encryptionInfo!.senderVerified).toBe(false);
expect(encryptionInfo!.sender).toBe("@bob:xyz");
expect(encryptionInfo!.senderDevice).toBe("DEVICE_ID");
const oldFormat = await oldToDeviceResolver.promise;
expect(oldFormat.isEncrypted()).toBe(true);
expect(oldFormat.getType()).toBe("m.test.type");
expect(oldFormat.getContent()["body"]).toBe("foo");
});
it("Should receive clear to-device message via ClientEvent", async () => {
await aliceClient.startClient();
await syncPromise(aliceClient);
const toDeviceEvent: IToDeviceEvent = {
sender: "@bob:xyz",
type: "m.test.type",
content: {
body: "foo",
},
};
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
processedToDeviceResolver.resolve(payload);
});
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
oldToDeviceResolver.resolve(event);
});
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
await syncPromise(aliceClient);
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
expect(message.type).toBe("m.test.type");
expect(message.content["body"]).toBe("foo");
// When the message is not encrypted, we don't have the encryptionInfo.
expect(encryptionInfo).toBeNull();
const oldFormat = await oldToDeviceResolver.promise;
expect(oldFormat.isEncrypted()).toBe(false);
expect(oldFormat.getType()).toBe("m.test.type");
expect(oldFormat.getContent()["body"]).toBe("foo");
});
});
});
+6 -4
View File
@@ -17,6 +17,7 @@ limitations under the License.
import "fake-indexeddb/auto";
import anotherjson from "another-json";
import debug from "debug";
import fetchMock from "fetch-mock-jest";
import { IDBFactory } from "fake-indexeddb";
import { createHash } from "crypto";
@@ -25,6 +26,7 @@ import Olm from "@matrix-org/olm";
import type FetchMock from "fetch-mock";
import {
createClient,
DebugLogger,
DeviceVerification,
type IContent,
type ICreateClientOpts,
@@ -84,7 +86,7 @@ jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
beforeAll(async () => {
// we use the libolm primitives in the test, so init the Olm library
await globalThis.Olm.init();
await Olm.init();
});
// load the rust library. This can take a few seconds on a slow GH worker.
@@ -110,7 +112,6 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
// we test with both crypto stacks...
describe("verification", () => {
/** the client under test */
let aliceClient: MatrixClient;
@@ -254,7 +255,7 @@ describe("verification", () => {
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
const olmSAS = new globalThis.Olm.SAS();
const olmSAS = new Olm.SAS();
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
// alice responds with a 'key' ...
@@ -348,7 +349,7 @@ describe("verification", () => {
// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
const olmSAS = new globalThis.Olm.SAS();
const olmSAS = new Olm.SAS();
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);
sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
@@ -1476,6 +1477,7 @@ describe("verification", () => {
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: "device_under_test",
logger: new DebugLogger(debug(`matrix-js-sdk:verification`)),
...opts,
});
await client.initRustCrypto();
+53
View File
@@ -267,6 +267,59 @@ describe("MatrixClient", function () {
});
});
describe("invite", function () {
it("should send request to /invite", async () => {
const roomId = "!roomId:server";
const userId = "@user:server";
httpBackend
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
.check((request) => {
expect(request.data).toEqual({ user_id: userId });
})
.respond(200, {});
const prom = client.invite(roomId, userId);
await httpBackend.flushAllExpected();
await prom;
httpBackend.verifyNoOutstandingExpectation();
});
it("accepts a stringy reason argument", async () => {
const roomId = "!roomId:server";
const userId = "@user:server";
httpBackend
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
.check((request) => {
expect(request.data).toEqual({ user_id: userId, reason: "testreason" });
})
.respond(200, {});
const prom = client.invite(roomId, userId, "testreason");
await httpBackend.flushAllExpected();
await prom;
httpBackend.verifyNoOutstandingExpectation();
});
it("accepts an options object with a reason", async () => {
const roomId = "!roomId:server";
const userId = "@user:server";
httpBackend
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
.check((request) => {
expect(request.data).toEqual({ user_id: userId, reason: "testreason" });
})
.respond(200, {});
const prom = client.invite(roomId, userId, { reason: "testreason" });
await httpBackend.flushAllExpected();
await prom;
httpBackend.verifyNoOutstandingExpectation();
});
});
describe("knockRoom", function () {
const roomId = "!some-room-id:example.org";
const reason = "some reason";
+105
View File
@@ -205,4 +205,109 @@ describe("MatrixClient opts", function () {
expect(res.event_id).toEqual("foo");
});
});
describe("with opts.queryParams", function () {
let client: MatrixClient;
let httpBackend: HttpBackend;
const userId = "@rsb-tbg:localhost";
beforeEach(function () {
httpBackend = new HttpBackend();
client = new MatrixClient({
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
store: new MemoryStore() as IStore,
baseUrl: baseUrl,
userId: userId,
accessToken: accessToken,
queryParams: { user_id: userId },
});
});
afterEach(function () {
client.stopClient();
httpBackend.verifyNoOutstandingExpectation();
return httpBackend.stop();
});
it("should include queryParams in matrix server requests", async () => {
const eventId = "$test:event";
httpBackend
.when("PUT", "/txn1")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {
event_id: eventId,
});
const [res] = await Promise.all([
client.sendTextMessage("!foo:bar", "test message", "txn1"),
httpBackend.flush("/txn1", 1),
]);
expect(res.event_id).toEqual(eventId);
});
it("should include queryParams in sync requests", async () => {
httpBackend
.when("GET", "/versions")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {});
httpBackend
.when("GET", "/pushrules")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {});
httpBackend
.when("POST", "/filter")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, { filter_id: "foo" });
httpBackend
.when("GET", "/sync")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, syncData);
client.startClient();
await httpBackend.flush("/versions", 1);
await httpBackend.flush("/pushrules", 1);
await httpBackend.flush("/filter", 1);
await Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]);
});
it("should merge queryParams with request-specific params", async () => {
const eventId = "$test:event";
httpBackend
.when("PUT", "/txn1")
.check((req) => {
// Should contain both global queryParams and request-specific params
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {
event_id: eventId,
});
const [res] = await Promise.all([
client.sendTextMessage("!foo:bar", "test message", "txn1"),
httpBackend.flush("/txn1", 1),
]);
expect(res.event_id).toEqual(eventId);
});
});
});
+1 -1
View File
@@ -126,7 +126,7 @@ describe("SlidingSyncSdk", () => {
// assign client/httpBackend globals
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
testOpts = testOpts || {};
const syncOpts: SyncApiOptions = {};
const syncOpts: SyncApiOptions = { logger };
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
-27
View File
@@ -1,27 +0,0 @@
/*
Copyright 2017 Vector creations Ltd
Copyright 2019 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 { logger } from "../src/logger";
// try to load the olm library.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
globalThis.Olm = require("@matrix-org/olm");
logger.log("loaded libolm");
} catch (e) {
logger.warn("unable to run crypto tests: libolm not available", e);
}
+5
View File
@@ -19,3 +19,8 @@ jest.mock("../src/http-api/utils", () => ({
// We mock timeoutSignal otherwise it causes tests to leave timers running
timeoutSignal: () => new AbortController().signal,
}));
// Dont make test fail too soon due to timeouts while debugging.
if (process.env.VSCODE_INSPECTOR_OPTIONS) {
jest.setTimeout(60 * 1000 * 5); // 5 minutes
}
+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",
},
{},
);
}
/**
+103 -8
View File
@@ -1,9 +1,7 @@
// load olm before the sdk if possible
import "../olm-loader";
import mkdebug from "debug";
// eslint-disable-next-line no-restricted-imports
import type EventEmitter from "events";
import { logger } from "../../src/logger";
import {
type IContent,
type IEvent,
@@ -27,6 +25,8 @@ import { eventMapperFor } from "../../src/event-mapper";
import { TEST_ROOM_ID } from "./test-data";
import { KnownMembership, type Membership } from "../../src/@types/membership";
const debug = mkdebug("test-utils");
/**
* Return a promise that is resolved when the client next emits a
* SYNCING event.
@@ -41,7 +41,7 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
const p = new Promise<void>((resolve) => {
const cb = (state: SyncState) => {
logger.log(`${Date.now()} syncPromise(${count}): ${state}`);
debug(`syncPromise(${count}): ${state}`);
if (state === SyncState.Syncing) {
resolve();
} else {
@@ -522,25 +522,25 @@ export async function awaitDecryption(
// already
if (event.getClearContent() !== null) {
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
debug(`event ${event.getId()} got decryption error; waiting`);
} else {
return event;
}
} else {
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
debug(`event ${event.getId()} is not yet decrypted; waiting`);
}
return new Promise((resolve) => {
if (waitOnDecryptionFailure) {
event.on(MatrixEventEvent.Decrypted, (ev, err) => {
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
debug(`MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
if (!err) {
resolve(ev);
}
});
} else {
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
debug(`MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
resolve(ev);
});
}
@@ -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."));
}
}
});
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
import { StubStore } from "../../src/store/stub";
import { type IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { SyncState } from "../../src/sync";
import { logger } from "../../src/logger.ts";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
@@ -55,7 +56,7 @@ describe("onResumedSync", () => {
}
});
queue = new ToDeviceMessageQueue(mockClient);
queue = new ToDeviceMessageQueue(mockClient, logger);
});
it("resends queue after connectivity restored", async () => {
+70 -37
View File
@@ -183,39 +183,43 @@ describe("Topic content helpers", () => {
it("creates fully defined event content without html", () => {
expect(makeTopicContent("pizza")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
mimetype: "text/plain",
},
],
},
});
});
it("creates fully defined event content with html", () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
},
});
});
it("creates an empty event when the topic is falsey", () => {
expect(makeTopicContent(undefined)).toEqual({
topic: undefined,
[M_TOPIC.name]: [],
[M_TOPIC.name]: { "m.text": [] },
});
expect(makeTopicContent(null)).toEqual({
topic: null,
[M_TOPIC.name]: [],
[M_TOPIC.name]: { "m.text": [] },
});
});
});
@@ -225,11 +229,13 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -240,12 +246,14 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
mimetype: "text/plain",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -256,12 +264,14 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -279,11 +289,34 @@ describe("Topic content helpers", () => {
});
});
it("uses legacy event content when new topic key is invalid", () => {
// TODO delete this test and re-enable the next one after support for the invalid form is removed
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
it("parses malformed event content with html topic", () => {
expect(
parseTopicContent({
"topic": "pizza",
"m.topic": {} as any,
"m.topic": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
] as any,
}),
).toEqual({
text: "pizza",
html: "<b>pizza</b>",
});
});
it.skip("uses legacy event content when new topic key is invalid", () => {
expect(
parseTopicContent({
"topic": "pizza",
"m.topic": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
] as any,
}),
).toEqual({
text: "pizza",
+7
View File
@@ -170,4 +170,11 @@ describe("Filter Component", function () {
expect(filter.check(noMatchEvent)).toBe(false);
});
});
describe("toJSON", () => {
it("should omit empty values", () => {
const filter = new FilterComponent({ types: ["m.room.message"], senders: ["@alice:example.com"] });
expect(filter.toJSON()).toEqual({ types: ["m.room.message"], senders: ["@alice:example.com"] });
});
});
});
+196 -34
View File
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked } from "jest-mock";
import type { Mocked, MockedFunction } from "jest-mock";
import { FetchHttpApi } from "../../../src/http-api/fetch";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import {
@@ -43,8 +42,8 @@ describe("FetchHttpApi", () => {
});
it("should support aborting multiple times", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
api.request(Method.Get, "/foo");
api.request(Method.Get, "/baz");
@@ -68,13 +67,13 @@ describe("FetchHttpApi", () => {
it("should fall back to global fetch if fetchFn not provided", () => {
globalThis.fetch = jest.fn();
expect(globalThis.fetch).not.toHaveBeenCalled();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
api.fetch("test");
expect(globalThis.fetch).toHaveBeenCalled();
});
it("should update identity server base url", () => {
const api = new FetchHttpApi<IHttpOpts>(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new FetchHttpApi<IHttpOpts>(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
expect(api.opts.idBaseUrl).toBeUndefined();
api.setIdBaseUrl("https://id.foo.bar");
expect(api.opts.idBaseUrl).toBe("https://id.foo.bar");
@@ -82,23 +81,35 @@ describe("FetchHttpApi", () => {
describe("idServerRequest", () => {
it("should throw if no idBaseUrl", () => {
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
expect(() => api.idServerRequest(Method.Get, "/test", {}, IdentityPrefix.V2)).toThrow(
"No identity server base URL set",
);
});
it("should send params as query string for GET requests", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
idBaseUrl,
prefix,
fetchFn,
onlyData: true,
});
api.idServerRequest(Method.Get, "/test", { foo: "bar", via: ["a", "b"] }, IdentityPrefix.V2);
expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).toBe("bar");
expect(fetchFn.mock.calls[0][0].searchParams.getAll("via")).toEqual(["a", "b"]);
});
it("should send params as body for non-GET requests", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
idBaseUrl,
prefix,
fetchFn,
onlyData: true,
});
const params = { foo: "bar", via: ["a", "b"] };
api.idServerRequest(Method.Post, "/test", params, IdentityPrefix.V2);
expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).not.toBe("bar");
@@ -106,21 +117,38 @@ describe("FetchHttpApi", () => {
});
it("should add Authorization header if token provided", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
idBaseUrl,
prefix,
fetchFn,
onlyData: true,
});
api.idServerRequest(Method.Post, "/test", {}, IdentityPrefix.V2, "token");
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
});
});
it("should return the Response object if onlyData=false", async () => {
const res = { ok: true };
const fetchFn = jest.fn().mockResolvedValue(res);
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: false });
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res);
it("should complain if constructed without `onlyData: true`", async () => {
expect(
() =>
new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
}),
).toThrow("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
});
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,40 +157,68 @@ 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 () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: false,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path");
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token");
});
it("should send token via headers by default", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
onlyData: true,
});
await api.authedRequest(Method.Get, "/path");
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
});
it("should not send a token if not calling `authedRequest`", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
onlyData: true,
});
api.request(Method.Get, "/path");
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
@@ -170,13 +226,14 @@ describe("FetchHttpApi", () => {
});
it("should ensure no token is leaked out via query params if sending via headers", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: true,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path", { access_token: "123" });
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
@@ -184,26 +241,28 @@ describe("FetchHttpApi", () => {
});
it("should not override manually specified access token via query params", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: false,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken");
});
it("should not override manually specified access token via header", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
accessToken: "token",
useAuthorizationHeader: true,
onlyData: true,
});
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
headers: { Authorization: "Bearer RealToken" },
@@ -212,8 +271,8 @@ describe("FetchHttpApi", () => {
});
it("should not override Accept header", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const fetchFn = makeMockFetchFn();
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
headers: { Accept: "text/html" },
});
@@ -236,7 +295,7 @@ describe("FetchHttpApi", () => {
),
});
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, onlyData: true });
await Promise.all([
emitPromise(emitter, HttpApiEvent.NoConsent),
@@ -246,9 +305,9 @@ describe("FetchHttpApi", () => {
describe("authedRequest", () => {
it("should not include token if unset", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const fetchFn = makeMockFetchFn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, onlyData: true });
await api.authedRequest(Method.Post, "/account/password");
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
});
@@ -277,6 +336,7 @@ describe("FetchHttpApi", () => {
const okayResponse = {
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ x: 1 }),
};
describe("without a tokenRefreshFunction", () => {
@@ -284,7 +344,14 @@ describe("FetchHttpApi", () => {
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
@@ -306,6 +373,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -327,6 +395,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -355,11 +424,12 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
headers: {},
});
expect(result).toEqual(okayResponse);
expect(result).toEqual({ x: 1 });
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(fetchFn).toHaveBeenCalledTimes(2);
@@ -398,6 +468,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -453,6 +524,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -473,7 +545,7 @@ describe("FetchHttpApi", () => {
const makeApi = (thisBaseUrl = baseUrl): FetchHttpApi<any> => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn });
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn, onlyData: true });
};
type TestParams = {
@@ -521,6 +593,89 @@ describe("FetchHttpApi", () => {
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
runTests(baseUrlWithTrailingSlash);
});
describe("extraParams handling", () => {
const makeApiWithExtraParams = (extraParams: QueryDict): FetchHttpApi<any> => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
return new FetchHttpApi(emitter, {
baseUrl: localBaseUrl,
prefix,
fetchFn,
onlyData: true,
extraParams,
});
};
const userId = "@rsb-tbg:localhost";
const encodedUserId = encodeURIComponent(userId);
it("should include extraParams in URL when no queryParams provided", () => {
const extraParams = { user_id: userId, version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const result = api.getUrl("/test");
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
});
it("should merge extraParams with queryParams", () => {
const extraParams = { user_id: userId, version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const queryParams = { userId: "123", filter: "active" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("user_id")!).toBe(userId);
expect(result.searchParams.get("version")!).toBe("1.0");
expect(result.searchParams.get("userId")!).toBe("123");
expect(result.searchParams.get("filter")!).toBe("active");
});
it("should allow queryParams to override extraParams", () => {
const extraParams = { user_id: "@default:localhost", version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const queryParams = { user_id: "@override:localhost", userId: "123" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("user_id")).toBe("@override:localhost");
expect(result.searchParams.get("version")!).toBe("1.0");
expect(result.searchParams.get("userId")!).toBe("123");
});
it("should handle empty extraParams", () => {
const extraParams = {};
const api = makeApiWithExtraParams(extraParams);
const queryParams = { userId: "123" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("userId")!).toBe("123");
expect(result.searchParams.has("user_id")).toBe(false);
});
it("should work when extraParams is undefined", () => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, onlyData: true });
const queryParams = { userId: "123" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("userId")!).toBe("123");
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?userId=123`);
});
it("should work when queryParams is undefined", () => {
const extraParams = { user_id: userId, version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const result = api.getUrl("/test");
expect(result.searchParams.get("user_id")!).toBe(userId);
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
});
});
});
it("should not log query parameters", async () => {
@@ -535,10 +690,11 @@ describe("FetchHttpApi", () => {
prefix,
fetchFn,
logger: mockLogger,
onlyData: true,
});
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
jest.advanceTimersByTime(1234);
responseResolvers.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
responseResolvers.resolve({ ok: true, status: 200, json: () => Promise.resolve("RESPONSE") } as Response);
await prom;
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
@@ -581,6 +737,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken: "ACCESS_TOKEN",
refreshToken: "REFRESH_TOKEN",
onlyData: true,
});
const prom1 = api.authedRequest(Method.Get, "/path1");
@@ -636,6 +793,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken: "ACCESS_TOKEN",
refreshToken: "REFRESH_TOKEN",
onlyData: true,
});
const prom1 = api.authedRequest(Method.Get, "/path1");
@@ -673,3 +831,7 @@ describe("FetchHttpApi", () => {
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
});
});
function makeMockFetchFn(): MockedFunction<any> {
return jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
}
+21 -14
View File
@@ -62,7 +62,7 @@ describe("MatrixHttpApi", () => {
it("should fall back to `fetch` where xhr is unavailable", async () => {
globalThis.XMLHttpRequest = undefined!;
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
upload = api.uploadContent({} as File);
await upload;
expect(fetchFn).toHaveBeenCalled();
@@ -70,7 +70,7 @@ describe("MatrixHttpApi", () => {
it("should prefer xhr where available", () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
upload = api.uploadContent({} as File);
expect(fetchFn).not.toHaveBeenCalled();
expect(xhr.open).toHaveBeenCalled();
@@ -82,6 +82,7 @@ describe("MatrixHttpApi", () => {
prefix,
accessToken: "token",
useAuthorizationHeader: false,
onlyData: true,
});
upload = api.uploadContent({} as File);
expect(xhr.open).toHaveBeenCalledWith(
@@ -96,6 +97,7 @@ describe("MatrixHttpApi", () => {
baseUrl,
prefix,
accessToken: "token",
onlyData: true,
});
upload = api.uploadContent({} as File);
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
@@ -103,7 +105,7 @@ describe("MatrixHttpApi", () => {
});
it("should include filename by default", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File, { name: "name" });
expect(xhr.open).toHaveBeenCalledWith(
Method.Post,
@@ -112,13 +114,13 @@ describe("MatrixHttpApi", () => {
});
it("should allow not sending the filename", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File, { name: "name", includeFilename: false });
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
});
it("should abort xhr when the upload is aborted", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
api.cancelUpload(upload);
expect(xhr.abort).toHaveBeenCalled();
@@ -126,7 +128,7 @@ describe("MatrixHttpApi", () => {
});
it("should timeout if no progress in 30s", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
jest.advanceTimersByTime(25000);
// @ts-ignore
@@ -138,7 +140,7 @@ describe("MatrixHttpApi", () => {
});
it("should call progressHandler", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
const progressHandler = jest.fn();
upload = api.uploadContent({} as File, { progressHandler });
const progressEvent = new Event("progress") as ProgressEvent;
@@ -154,7 +156,7 @@ describe("MatrixHttpApi", () => {
});
it("should error when no response body", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -167,7 +169,7 @@ describe("MatrixHttpApi", () => {
});
it("should error on a 400-code", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -184,7 +186,7 @@ describe("MatrixHttpApi", () => {
});
it("should return response on successful upload", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -198,14 +200,14 @@ describe("MatrixHttpApi", () => {
});
it("should abort xhr when calling `cancelUpload`", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
expect(api.cancelUpload(upload)).toBeTruthy();
expect(xhr.abort).toHaveBeenCalled();
});
it("should return false when `cancelUpload` is called but unsuccessful", async () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
xhr.readyState = DONE;
@@ -220,7 +222,7 @@ describe("MatrixHttpApi", () => {
});
it("should return active uploads in `getCurrentUploads`", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
upload = api.uploadContent({} as File);
expect(api.getCurrentUploads().find((u) => u.promise === upload)).toBeTruthy();
api.cancelUpload(upload);
@@ -228,7 +230,12 @@ describe("MatrixHttpApi", () => {
});
it("should return expected object from `getContentUri`", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, accessToken: "token" });
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
accessToken: "token",
onlyData: true,
});
expect(api.getContentUri()).toMatchSnapshot();
});
});
+40 -1
View File
@@ -18,7 +18,7 @@ limitations under the License.
import loglevel from "loglevel";
import { logger } from "../../src/logger.ts";
import { DebugLogger, logger } from "../../src/logger.ts";
afterEach(() => {
jest.restoreAllMocks();
@@ -49,3 +49,42 @@ describe("logger", () => {
expect(console.debug).toHaveBeenCalledWith("[prefix1][prefix2]", "test2");
});
});
describe("DebugLogger", () => {
it("should handle empty log messages", () => {
const mockTarget = jest.fn();
const logger = new DebugLogger(mockTarget as any);
logger.info();
expect(mockTarget).toHaveBeenCalledTimes(1);
expect(mockTarget).toHaveBeenCalledWith("[INFO] ");
});
it("should handle logging an Error", () => {
const mockTarget = jest.fn();
const logger = new DebugLogger(mockTarget as any);
// If there is a stack and a message, we use the stack.
const error = new Error("I am an error");
logger.error(error);
expect(mockTarget).toHaveBeenCalledTimes(1);
expect(mockTarget).toHaveBeenCalledWith(expect.stringMatching(/^\[ERROR\] Error: I am an error\n\s*at/));
mockTarget.mockClear();
// If there is only a message, we use that.
error.stack = undefined;
logger.error(error);
expect(mockTarget).toHaveBeenCalledTimes(1);
expect(mockTarget).toHaveBeenCalledWith("[ERROR] I am an error");
});
it("should handle logging an object", () => {
const mockTarget = jest.fn();
const logger = new DebugLogger(mockTarget as any);
const obj = { a: 1 };
logger.warn(obj);
expect(mockTarget).toHaveBeenCalledTimes(1);
expect(mockTarget).toHaveBeenCalledWith("[WARN] %O", obj);
});
});
+23 -18
View File
@@ -2998,6 +2998,8 @@ describe("MatrixClient", function () {
replacedByDynamicPredecessor2,
room2,
];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room1.addLiveEvents(
[
roomCreateEvent(room1.roomId, replacedByCreate1.roomId),
@@ -3036,6 +3038,7 @@ describe("MatrixClient", function () {
replacedByDynamicPredecessor2,
};
}
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
@@ -3062,6 +3065,8 @@ describe("MatrixClient", function () {
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true });
room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], { addToState: true });
replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true });
@@ -3127,6 +3132,24 @@ describe("MatrixClient", function () {
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
it("should ignore room replacements which are not reciprocated by the predecessor", () => {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
// Room 2 claims to replace room 1 but room 1 does not agree
const room2 = new Room("replacedRoom1", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, room2];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)], { addToState: true });
// When we ask for the visible rooms
const rooms = client.getVisibleRooms();
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
});
describe("getRoomUpgradeHistory", () => {
@@ -3597,24 +3620,6 @@ describe("MatrixClient", function () {
});
});
describe("getAuthIssuer", () => {
it("should use unstable prefix", async () => {
httpLookups = [
{
method: "GET",
path: `/auth_issuer`,
data: {
issuer: "https://issuer/",
},
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
];
await expect(client.getAuthIssuer()).resolves.toEqual({ issuer: "https://issuer/" });
expect(httpLookups.length).toEqual(0);
});
});
describe("getAuthMetadata", () => {
beforeEach(() => {
fetchMock.mockReset();
+214 -219
View File
@@ -16,16 +16,18 @@ limitations under the License.
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring";
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
const mockFocus = { type: "mock" };
const textEncoder = new TextEncoder();
const callSession = { id: "", application: "m.call" };
describe("MatrixRTCSession", () => {
let client: MatrixClient;
let sess: MatrixRTCSession | undefined;
@@ -47,16 +49,35 @@ describe("MatrixRTCSession", () => {
describe("roomSessionForRoom", () => {
it("creates a room-scoped session from room state", () => {
const mockRoom = makeMockRoom(membershipTemplate);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].callId).toEqual("");
expect(sess?.memberships[0].sessionDescription.id).toEqual("");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.callId).toEqual("");
expect(sess?.sessionDescription.id).toEqual("");
});
it("ignores memberships where application is not m.call", () => {
const testMembership = Object.assign({}, membershipTemplate, {
application: "not-m.call",
});
const mockRoom = makeMockRoom([testMembership]);
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores memberships where callId is not empty", () => {
const testMembership = Object.assign({}, membershipTemplate, {
call_id: "not-empty",
scope: "m.room",
});
const mockRoom = makeMockRoom([testMembership]);
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores expired memberships events", () => {
@@ -67,16 +88,16 @@ describe("MatrixRTCSession", () => {
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
jest.advanceTimersByTime(2000);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
jest.useRealTimers();
});
it("ignores memberships events of members not in the room", () => {
const mockRoom = makeMockRoom(membershipTemplate);
const mockRoom = makeMockRoom([membershipTemplate]);
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(0);
});
@@ -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,77 +201,9 @@ describe("MatrixRTCSession", () => {
const testMembership = Object.assign({}, membershipTemplate);
(testMembership.call_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
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);
expect(sess.memberships).toHaveLength(0);
});
});
describe("updateCallMembershipEvent", () => {
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
const joinSessionConfig = {};
const sessionMembershipData: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA_session",
focus_active: mockFocus,
foci_preferred: [mockFocus],
};
let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock;
let sentStateEvent: Promise<void>;
let sentDelayedState: Promise<void>;
beforeEach(() => {
sentStateEvent = new Promise((resolve) => {
sendStateEventMock = jest.fn(resolve);
});
sentDelayedState = new Promise((resolve) => {
sendDelayedStateMock = jest.fn(() => {
resolve();
return {
delay_id: "id",
};
});
});
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
});
async function testSession(membershipData: SessionMembershipData): Promise<void> {
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(sendStateEventMock).toHaveBeenCalledTimes(1);
await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(sendDelayedStateMock).toHaveBeenCalledTimes(1);
}
it("sends events", async () => {
await testSession(sessionMembershipData);
});
});
describe("getOldestMembership", () => {
@@ -263,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();
});
@@ -288,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",
@@ -308,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",
@@ -320,34 +273,22 @@ describe("MatrixRTCSession", () => {
describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock;
let sendEventMock: jest.Mock;
let sendStateEventMock: jest.Mock;
let sentStateEvent: Promise<void>;
let sentDelayedState: Promise<void>;
beforeEach(() => {
sentStateEvent = new Promise((resolve) => {
sendStateEventMock = jest.fn(resolve);
});
sentDelayedState = new Promise((resolve) => {
sendDelayedStateMock = jest.fn(() => {
resolve();
return {
delay_id: "id",
};
});
});
sendEventMock = jest.fn();
sendEventMock = jest.fn().mockResolvedValue(undefined);
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
client.sendEvent = sendEventMock;
client._unstable_updateDelayedEvent = jest.fn();
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
});
afterEach(async () => {
@@ -368,72 +309,102 @@ describe("MatrixRTCSession", () => {
expect(sess!.isJoined()).toEqual(true);
});
it("sends a membership event when joining a call", async () => {
const realSetTimeout = setTimeout;
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus], mockFocus);
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: DEFAULT_EXPIRE_DURATION,
foci_preferred: [mockFocus],
focus_active: {
focus_selection: "oldest_membership",
type: "livekit",
},
},
"_@alice:example.org_AAAAAAA",
);
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
// Because we actually want to send the state
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
// For checking if the delayed event is still there or got removed while sending the state.
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
// For scheduling the delayed event
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
// This returns no error so we do not check if we reschedule the event again. this is done in another test.
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
// Simulate a join, including the update to the room state
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
sendEventMock
.mockResolvedValueOnce({ event_id: "legacy-evt" })
.mockResolvedValueOnce({ event_id: "new-evt" });
const didSendEventFn = jest.fn();
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
// Create an additional listener to create a promise that resolves after the emission.
const didSendNotification = new Promise((resolve) => {
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
});
jest.useRealTimers();
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
"m.mentions": { user_ids: [], room: true },
"notification_type": "ring",
"m.relates_to": {
event_id: ownMembershipId,
rel_type: "m.reference",
},
"lifetime": 30000,
"sender_ts": expect.any(Number),
});
// Check if deprecated notify event is also sent.
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": "ring",
"call_id": "",
});
await didSendNotification;
// And ensure we emitted the DidSendCallNotification event with both payloads
expect(didSendEventFn).toHaveBeenCalledWith(
{
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
"notification_type": "ring",
"sender_ts": expect.any(Number),
},
{
"application": "m.call",
"call_id": "",
"event_id": "legacy-evt",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
},
);
});
it("uses membershipEventExpiryMs from join config", async () => {
const realSetTimeout = setTimeout;
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus], mockFocus, { membershipEventExpiryMs: 60000 });
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: 60000,
foci_preferred: [mockFocus],
focus_active: {
focus_selection: "oldest_membership",
type: "livekit",
},
},
"_@alice:example.org_AAAAAAA",
);
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
jest.useRealTimers();
it("doesn't send a notification when joining an existing call", async () => {
// Add another member to the call so that it is considered an existing call
mockRoomState(mockRoom, [membershipTemplate]);
sess!.onRTCSessionMemberUpdate();
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
expect(client.sendEvent).not.toHaveBeenCalled();
});
it("doesn't send a notification when someone else starts the call faster than us", async () => {
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
// But this time we want to simulate a race condition in which we receive a state event
// from someone else, starting the call before our own state event has been sent
mockRoomState(mockRoom, [membershipTemplate]);
sess!.onRTCSessionMemberUpdate();
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
// We assume that the responsibility to send a notification, if any, lies with the other
// participant that won the race
expect(client.sendEvent).not.toHaveBeenCalled();
});
});
describe("onMembershipsChanged", () => {
it("does not emit if no membership changes", () => {
const mockRoom = makeMockRoom(membershipTemplate);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
@@ -443,13 +414,13 @@ describe("MatrixRTCSession", () => {
});
it("emits on membership changes", () => {
const mockRoom = makeMockRoom(membershipTemplate);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId));
mockRoomState(mockRoom, []);
sess.onRTCSessionMemberUpdate();
expect(onMembershipsChanged).toHaveBeenCalled();
@@ -489,9 +460,9 @@ describe("MatrixRTCSession", () => {
let sendToDeviceMock: jest.Mock;
beforeEach(() => {
sendStateEventMock = jest.fn();
sendDelayedStateMock = jest.fn();
sendEventMock = jest.fn();
sendStateEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
sendDelayedStateMock = jest.fn().mockResolvedValue({ event_id: "id" });
sendEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
sendToDeviceMock = jest.fn();
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
@@ -499,7 +470,7 @@ describe("MatrixRTCSession", () => {
client.encryptAndSendToDevice = sendToDeviceMock;
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
});
afterEach(async () => {
@@ -569,24 +540,22 @@ describe("MatrixRTCSession", () => {
let firstEventSent = false;
try {
const eventSentPromise = new Promise<void>((resolve) => {
const eventSentPromise = new Promise<{ event_id: string }>((resolve) => {
sendEventMock.mockImplementation(() => {
if (!firstEventSent) {
jest.advanceTimersByTime(10000);
firstEventSent = true;
const e = new Error() as MatrixError;
e.data = {};
throw e;
} else {
resolve();
resolve({ event_id: "id" });
}
});
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await jest.runAllTimersAsync();
// wait for the encryption event to get sent
await jest.advanceTimersByTimeAsync(5000);
await eventSentPromise;
expect(sendEventMock).toHaveBeenCalledTimes(2);
@@ -620,7 +589,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) => {
@@ -635,18 +604,14 @@ describe("MatrixRTCSession", () => {
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
// member2 leaves triggering key rotation
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate]);
sess.onRTCSessionMemberUpdate();
// member2 re-joins which should trigger an immediate re-send
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();
// but, that immediate resend is throttled so we need to wait a bit
jest.advanceTimersByTime(1000);
@@ -673,7 +638,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));
@@ -697,9 +662,7 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();
await keysSentPromise2;
@@ -724,11 +687,9 @@ describe("MatrixRTCSession", () => {
});
const mockRoom = makeMockRoom([member1, member2]);
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
mockRoomState(mockRoom, [member1, member2]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -773,11 +734,7 @@ describe("MatrixRTCSession", () => {
};
const mockRoom = makeMockRoom([member1, member2]);
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -806,6 +763,7 @@ describe("MatrixRTCSession", () => {
// update created_ts
member2.created_ts = 5000;
mockRoomState(mockRoom, [member1, member2]);
const keysSentPromise2 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
@@ -840,7 +798,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(
@@ -869,20 +827,34 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate]);
sess.onRTCSessionMemberUpdate();
jest.advanceTimersByTime(KEY_DELAY);
expect(sendKeySpy).toHaveBeenCalledTimes(1);
// check that we send the key with index 1 even though the send gets delayed when leaving.
// this makes sure we do not use an index that is one too old.
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
expect(sendKeySpy).toHaveBeenLastCalledWith(
expect.any(String),
1,
sess.memberships.map((m) => ({
userId: m.sender,
deviceId: m.deviceId,
membershipTs: m.createdTs(),
})),
);
// fake a condition in which we send another encryption key event.
// this could happen do to someone joining the call.
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
expect(sendKeySpy).toHaveBeenLastCalledWith(
expect.any(String),
1,
sess.memberships.map((m) => ({
userId: m.sender,
deviceId: m.deviceId,
membershipTs: m.createdTs(),
})),
);
jest.advanceTimersByTime(7000);
const secondKeysPayload = await keysSentPromise2;
@@ -900,7 +872,7 @@ describe("MatrixRTCSession", () => {
it("wraps key index around to 0 when it reaches the maximum", async () => {
// this should give us keys with index [0...255, 0, 1]
const membersToTest = 258;
const members: SessionMembershipData[] = [];
const members: MembershipData[] = [];
for (let i = 0; i < membersToTest; i++) {
members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` }));
}
@@ -916,15 +888,11 @@ describe("MatrixRTCSession", () => {
if (i === 0) {
// if first time around then set up the session
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
} else {
// otherwise update the state reducing the membership each time in order to trigger key rotation
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(
makeMockRoomState(members.slice(0, membersToTest - i), mockRoom.roomId),
);
mockRoomState(mockRoom, members.slice(0, membersToTest - i));
}
sess!.onRTCSessionMemberUpdate();
@@ -946,7 +914,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);
@@ -965,9 +933,7 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();
await new Promise((resolve) => {
@@ -989,17 +955,20 @@ describe("MatrixRTCSession", () => {
});
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, {
manageMediaKeys: true,
useNewMembershipManager: true,
useExperimentalToDeviceTransport: true,
});
sess.onRTCSessionMemberUpdate();
await keySentPromise;
expect(sendToDeviceMock).toHaveBeenCalled();
// Access private to test
expect(sess["encryptionManager"]).toBeInstanceOf(RTCEncryptionManager);
} finally {
jest.useRealTimers();
}
@@ -1009,7 +978,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", {
@@ -1034,7 +1003,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", {
@@ -1060,7 +1029,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", {
@@ -1111,7 +1080,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(
@@ -1170,7 +1139,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(
@@ -1214,7 +1183,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(
@@ -1237,7 +1206,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);
@@ -1290,5 +1259,31 @@ describe("MatrixRTCSession", () => {
}
});
});
describe("read status", () => {
it("returns the correct probablyLeft status", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess!.probablyLeft).toBe(undefined);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(sess!.probablyLeft).toBe(false);
// Simulate the membership manager believing the user has left
const accessPrivateFieldsSession = sess as unknown as {
membershipManager: { state: { probablyLeft: boolean } };
};
accessPrivateFieldsSession.membershipManager.state.probablyLeft = true;
expect(sess!.probablyLeft).toBe(true);
});
it("returns membershipStatus once joinRoomSession got called", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess!.membershipStatus).toBe(undefined);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(sess!.membershipStatus).toBe(Status.Connecting);
});
});
});
});
@@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mock } from "jest-mock";
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
import { logger } from "../../../src/logger";
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
@@ -49,24 +48,102 @@ describe("MatrixRTCSessionManager", () => {
}
});
it("Doesn't fire event if unrelated sessions starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Fires event when session ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom(membershipTemplate);
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
(room1.getLiveTimeline as Mock).mockReturnValue({
getState: jest.fn().mockReturnValue(makeMockRoomState([{}], room1.roomId)),
});
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("")[0];
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Fires correctly with for with custom sessionDescription", () => {
const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
// manually start the session manager (its not the default one started by the client)
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
jest.spyOn(client, "getRoom").mockReturnValue(room2);
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
it("Doesn't fire event if unrelated sessions ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
});
+236 -97
View File
@@ -1,6 +1,3 @@
/**
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
*/
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
@@ -19,7 +16,14 @@ limitations under the License.
import { type MockedFunction, type Mock } from "jest-mock";
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
import {
type EmptyObject,
EventType,
HTTPError,
MatrixError,
UnsupportedDelayedEventsEndpointError,
type Room,
} from "../../../src";
import {
MembershipManagerEvent,
Status,
@@ -27,43 +31,49 @@ import {
type LivekitFocusActive,
type SessionMembershipData,
} from "../../../src/matrixrtc";
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
import { logger } from "../../../src/logger.ts";
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementation(() => {
resolve();
return returnVal ?? Promise.resolve();
});
/**
* Create a promise that will resolve once a mocked method is called.
* @param method The method to wait for.
* @param returnVal Provide an optional value that the mocked method should return. (use Promise.resolve(val) or Promise.reject(err))
* @returns The promise that resolves once the method is called.
*/
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
method.mockImplementation(() => {
resolve();
return returnVal ?? Promise.resolve();
});
return promise;
}
/** See waitForMockCall */
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementationOnce(() => {
resolve();
return returnVal ?? Promise.resolve();
});
const { promise, resolve } = Promise.withResolvers<void>();
method.mockImplementationOnce(() => {
resolve();
return returnVal ?? Promise.resolve();
});
return promise;
}
function createAsyncHandle(method: MockedFunction<any>) {
const { reject, resolve, promise } = Promise.withResolvers<void>();
/**
* A handle to control when in the test flow the provided method resolves (or gets rejected).
* @param method The method to control the resolve timing.
* @returns
*/
function createAsyncHandle<T>(method: MockedFunction<any>) {
const { reject, resolve, promise } = Promise.withResolvers<T>();
method.mockImplementation(() => promise);
return { reject, resolve };
}
/**
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
*/
const callSession = { id: "", application: "m.call" };
describe.each([
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
])("$description", ({ TestMembershipManager }) => {
describe("MembershipManager", () => {
let client: MockClient;
let room: Room;
const focusActive: LivekitFocusActive = {
@@ -80,7 +90,7 @@ describe.each([
// Default to fake timers.
jest.useFakeTimers();
client = makeMockClient("@alice:example.org", "AAAAAAA");
room = makeMockRoom(membershipTemplate);
room = makeMockRoom([membershipTemplate]);
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
@@ -94,12 +104,12 @@ describe.each([
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -110,13 +120,13 @@ describe.each([
it("sends a membership event and schedules delayed leave when joining a call", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
memberManager.join([focus], focusActive);
// expects
await waitForMockCall(client.sendStateEvent);
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
@@ -129,7 +139,7 @@ describe.each([
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
@@ -137,13 +147,13 @@ describe.each([
{ 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 TestMembershipManager(undefined, room, client, () => undefined);
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
@@ -188,7 +198,7 @@ describe.each([
});
});
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" });
@@ -212,13 +222,14 @@ describe.each([
return Promise.reject(error);
});
});
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{
delayedLeaveEventDelayMs: 9000,
},
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
@@ -275,7 +286,7 @@ describe.each([
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 TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
@@ -287,18 +298,19 @@ describe.each([
});
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 TestMembershipManager({}, 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);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 123456 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
@@ -306,17 +318,57 @@ describe.each([
{ delay: 123456 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
});
});
it("rejoins if delayed event is not found (404)", async () => {
const RESTART_DELAY = 15000;
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: RESTART_DELAY },
room,
client,
() => undefined,
callSession,
);
// Join with the membership manager
manager.join([focus], focusActive);
expect(manager.status).toBe(Status.Connecting);
// Let the scheduler run one iteration so that we can send the join state event
await jest.runOnlyPendingTimersAsync();
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
// Now that we are connected, we set up the mocks.
// We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave:
// - We wait until the delayed event gets sent and then mock its response to be "not found."
// - We enforce a race condition between the sync that informs us that our call membership state event was set to "left"
// and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent.
// - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left."
// (onRTCSessionMemberUpdate)
// - Only then do we resolve the sending of the delayed event.
// - We test that the manager acknowledges the leave and sends a new membership state event.
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
new MatrixError({ errcode: "M_NOT_FOUND" }),
);
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await jest.advanceTimersByTimeAsync(RESTART_DELAY);
// first simulate the sync, then resolve sending the delayed event.
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
// Let the scheduler run one iteration so that the new join gets sent
await jest.runOnlyPendingTimersAsync();
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
});
it("uses membershipEventExpiryMs from config", async () => {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ membershipEventExpiryMs: 1234567 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
@@ -336,12 +388,12 @@ describe.each([
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 TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
@@ -353,7 +405,7 @@ describe.each([
describe("leave()", () => {
// TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
@@ -361,7 +413,7 @@ describe.each([
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new TestMembershipManager({}, 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");
@@ -372,12 +424,11 @@ describe.each([
room.roomId,
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
});
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
it("does nothing if not joined !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
it("does nothing if not joined", () => {
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();
@@ -387,7 +438,7 @@ describe.each([
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new TestMembershipManager({}, 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);
@@ -403,11 +454,11 @@ describe.each([
type: "livekit",
},
],
device_id: client.getDeviceId(),
user_id: client.getUserId()!,
device_id: client.getDeviceId()!,
created_ts: 1000,
},
room.roomId,
client.getUserId()!,
),
);
expect(manager.getActiveFocus()).toStrictEqual(focus);
@@ -422,7 +473,7 @@ describe.each([
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new TestMembershipManager({}, 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);
});
@@ -430,7 +481,7 @@ describe.each([
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new TestMembershipManager({}, 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();
@@ -438,7 +489,7 @@ describe.each([
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new TestMembershipManager({}, 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];
@@ -449,7 +500,10 @@ describe.each([
await manager.onRTCSessionMemberUpdate([
mockCallMembership(membershipTemplate, room.roomId),
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
mockCallMembership(
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
room.roomId,
),
]);
await jest.advanceTimersByTimeAsync(1);
@@ -459,7 +513,7 @@ describe.each([
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new TestMembershipManager({}, 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`
@@ -475,16 +529,43 @@ describe.each([
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
describe("background timers", () => {
it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
@@ -506,16 +587,17 @@ describe.each([
}
});
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
// because the expires logic was removed for the legacy call manager.
// Delayed events should replace it entirely but before they have wide adoption
// the expiration logic still makes sense.
// TODO: Add git commit when we removed it.
async function testExpires(expire: number, headroom?: number) {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
@@ -529,23 +611,24 @@ describe.each([
expect(sentMembership.expires).toBe(expire * i);
}
}
it("extends `expires` when call still active !FailsForLegacy", async () => {
it("extends `expires` when call still active", async () => {
await testExpires(10_000);
});
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
it("extends `expires` using headroom configuration", async () => {
await testExpires(10_000, 1_000);
});
});
describe("status updates", () => {
it("starts 'Disconnected' !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
it("starts 'Disconnected'", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
expect(manager.status).toBe(Status.Disconnected);
});
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
it("emits 'Connection' and 'Connected' after join", async () => {
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
const manager = new TestMembershipManager({}, 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);
@@ -558,8 +641,8 @@ describe.each([
await jest.advanceTimersByTimeAsync(1);
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
});
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -575,7 +658,7 @@ describe.each([
it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -592,9 +675,8 @@ describe.each([
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
it("abandons retry loop and sends new own membership if not present anymore", async () => {
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -603,7 +685,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, 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);
@@ -620,11 +702,10 @@ describe.each([
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
it("abandons retry loop if leave() was called before sending state event", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -649,7 +730,7 @@ describe.each([
});
});
describe("retries sending update delayed leave event restart", () => {
it("resends the initial check delayed update event !FailsForLegacy", async () => {
it("resends the initial check delayed update event", async () => {
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
@@ -659,7 +740,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
// Hit rate limit
@@ -680,8 +761,8 @@ describe.each([
});
});
describe("unrecoverable errors", () => {
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries for initial delayed event creation", async () => {
const delayEventSendError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new MatrixError(
@@ -692,7 +773,7 @@ describe.each([
new Headers({ "Retry-After": "2" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -700,8 +781,8 @@ describe.each([
}
expect(delayEventSendError).toHaveBeenCalled();
});
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries", async () => {
const delayEventRestartError = jest.fn();
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
@@ -712,7 +793,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -720,23 +801,24 @@ describe.each([
}
expect(delayEventRestartError).toHaveBeenCalled();
});
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
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 TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("retries before failing in case its a network error !FailsForLegacy", async () => {
it("retries before failing in case its a network error", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive, unrecoverableError);
for (let retries = 0; retries < 7; retries++) {
@@ -749,12 +831,12 @@ describe.each([
);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -762,13 +844,70 @@ describe.each([
expect(client.sendStateEvent).toHaveBeenCalled();
});
});
describe("probablyLeft", () => {
it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => {
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 10000 },
room,
client,
() => undefined,
callSession,
);
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
const probablyLeftEmit = jest.fn();
manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit);
manager.join([focus], focusActive);
try {
// Let the scheduler run one iteration so that we can send the join state event
await waitForMockCall(client._unstable_updateDelayedEvent);
// We never resolve the delayed event so that we can test the probablyLeft event.
// This simulates the case where the server does not respond to the delayed event.
client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000.
// We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding.
// The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test.
await jest.advanceTimersByTimeAsync(5000);
// No emission after 5s
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(4999);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms.
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue({});
// Emit after 10s
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4);
expect(probablyLeftEmit).toHaveBeenCalledWith(true);
// Mock a sync which does not include our own membership
await manager.onRTCSessionMemberUpdate([]);
// Wait for the current ongoing delayed event sending to finish
await jest.advanceTimersByTimeAsync(1);
// We should send a new state event and an associated delayed leave event.
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
// At the same time we expect the probablyLeft event to be emitted with false so we are back operational.
expect(probablyLeftEmit).toHaveBeenCalledWith(false);
} finally {
rejectStuckPromise();
}
});
});
});
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom(membershipTemplate);
const room = makeMockRoom([membershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
const spy = jest.spyOn(console, "error");
// Double join
@@ -777,5 +916,5 @@ it("Should prefix log with MembershipManager used", () => {
expect(spy).toHaveBeenCalled();
const logline: string = spy.mock.calls[0][0];
expect(logline.startsWith("[NewMembershipManager]")).toBe(true);
expect(logline.startsWith("[MembershipManager]")).toBe(true);
});
@@ -0,0 +1,43 @@
/*
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 { OutdatedKeyFilter } from "../../../src/matrixrtc/utils.ts";
import { type InboundEncryptionSession } from "../../../src/matrixrtc";
describe("OutdatedKeyFilter Test", () => {
it("Should buffer and disambiguate keys by timestamp", () => {
const filter = new OutdatedKeyFilter();
const aKey = fakeInboundSessionWithTimestamp(1000);
const olderKey = fakeInboundSessionWithTimestamp(300);
// Simulate receiving out of order keys
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
// Then we receive the most recent key out of order
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
// this key is older and should be ignored even if received after
expect(isOutdated).toBe(true);
});
function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession {
return {
keyIndex: 0,
creationTS: ts,
participantId: "@alice:localhost|ABCDE",
key: new Uint8Array(16),
};
}
});
@@ -0,0 +1,896 @@
/*
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 { type Mocked } from "jest-mock";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
import { logger, type Logger } from "../../../src/logger.ts";
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
describe("RTCEncryptionManager", () => {
// The manager being tested
let encryptionManager: RTCEncryptionManager;
let getMembershipMock: jest.Mock;
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: jest.Mock;
beforeEach(() => {
statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
getMembershipMock = jest.fn().mockReturnValue([]);
onEncryptionKeysChanged = jest.fn();
mockTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
);
});
it("should start and stop the transport properly", () => {
encryptionManager.join(undefined);
expect(mockTransport.start).toHaveBeenCalledTimes(1);
expect(mockTransport.on).toHaveBeenCalledTimes(1);
expect(mockTransport.on).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
encryptionManager.leave();
expect(mockTransport.stop).toHaveBeenCalledTimes(1);
expect(mockTransport.off).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
});
describe("Sharing Keys", () => {
it("Set up my key asap even if no key distribution is needed", () => {
getMembershipMock.mockReturnValue([]);
encryptionManager.join(undefined);
// After join it is too early, key might be lost as no one is listening yet
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
encryptionManager.onMembershipsUpdate();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
});
it("Should distribute keys to members on join", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
await jest.runOnlyPendingTimersAsync();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
"@alice:example.org:DEVICE01",
);
});
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
membershipTs: 1000,
},
],
);
await jest.advanceTimersByTimeAsync(1);
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
mockTransport.sendKey.mockClear();
onEncryptionKeysChanged.mockClear();
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
getMembershipMock.mockReturnValue(members);
// There are no membership change but the callMembership ts has changed (reset?)
// Resend the key
encryptionManager.onMembershipsUpdate();
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// Re send the same key to that user
0,
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
membershipTs: 2000,
},
],
);
});
it("Should not rotate key when a user join within the rotation grace period", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
const gracePeriod = 15_000; // 15 seconds
// initial rollout
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
// Carl joins, within the grace period
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
await jest.advanceTimersByTimeAsync(gracePeriod / 2);
encryptionManager.onMembershipsUpdate();
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should not have incremented the key index
0,
// And send it to the newly joined only
[{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1000 }],
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
// Test an edge case where the use key delay is higher than the grace period.
// This means that no matter what, the key once rolled out will be too old to be re-used for the new member that
// joined within the grace period.
// So we expect another rotation to happen in all cases where a new member joins.
it("test grace period lower than delay period", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
const gracePeriod = 3_000; // 3 seconds
const useKeyDelay = gracePeriod + 2_000; // 5 seconds
// initial rollout
encryptionManager.join({
useKeyDelay,
keyRotationGracePeriodMs: gracePeriod,
});
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
// The existing members have been talking for 5mn
await jest.advanceTimersByTimeAsync(5 * 60 * 1000);
// A new member joins, that should trigger a key rotation.
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
// A new member joins, within the grace period, but under the delay period
members.push(aCallMembership("@david:example.org", "DAVDEVICE"));
await jest.advanceTimersByTimeAsync((useKeyDelay - gracePeriod) / 2);
encryptionManager.onMembershipsUpdate();
// Wait past the delay period
await jest.advanceTimersByTimeAsync(5_000);
// Even though the new member joined within the grace period, the key should be rotated because once the delay period has passed
// also the grace period is exceeded/the key is too old to be reshared.
// CARLDEVICE should have received a key with index 1 and another one with index 2
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 1, "@carl:example.org", "CARLDEVICE");
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 2, "@carl:example.org", "CARLDEVICE");
// Of course, should not have received the first key
expectKeyAtIndexNotToHaveBeenSentTo(mockTransport, 0, "@carl:example.org", "CARLDEVICE");
// DAVDEVICE should only have received a key with index 2
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 2, "@david:example.org", "DAVDEVICE");
expectKeyAtIndexNotToHaveBeenSentTo(mockTransport, 1, "@david:example.org", "DAVDEVICE");
});
it("Should rotate key when a user join past the rotation grace period", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
const gracePeriod = 15_000; // 15 seconds
// initial rollout
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
await jest.advanceTimersByTimeAsync(gracePeriod + 1000);
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
encryptionManager.onMembershipsUpdate();
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should have incremented the key index
1,
// And send it to everyone
[
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE" }),
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE2" }),
expect.objectContaining({ userId: "@carl:example.org", deviceId: "CARLDEVICE" }),
],
);
// Wait for useKeyDelay to pass
await jest.advanceTimersByTimeAsync(5000);
expect(onEncryptionKeysChanged).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not rotate key when several users join within the rotation grace period", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// initial rollout
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
const newJoiners = [
aCallMembership("@carl:example.org", "CARLDEVICE"),
aCallMembership("@dave:example.org", "DAVEDEVICE"),
aCallMembership("@eve:example.org", "EVEDEVICE"),
aCallMembership("@frank:example.org", "FRANKDEVICE"),
aCallMembership("@george:example.org", "GEORGEDEVICE"),
];
for (const newJoiner of newJoiners) {
members.push(newJoiner);
getMembershipMock.mockReturnValue(members);
await jest.advanceTimersByTimeAsync(1_000);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
}
expect(mockTransport.sendKey).toHaveBeenCalledTimes(newJoiners.length);
for (const newJoiner of newJoiners) {
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should not have incremented the key index
0,
// And send it to the new joiners only
expect.arrayContaining([
expect.objectContaining({ userId: newJoiner.sender, deviceId: newJoiner.deviceId }),
]),
);
}
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
});
it("Should not resend keys when no changes", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// initial rollout
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(200);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(100);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(50);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(100);
expect(mockTransport.sendKey).not.toHaveBeenCalled();
});
it("Should rotate key when a user leaves and delay the rollout", 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(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
// initial rollout
expect(mockTransport.sendKey).toHaveBeenCalled();
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(updatedMembers);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(200);
// The is rotated but not rolled out yet to give time for the key to be sent
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should have incremented the key index
1,
// And send it to the updated members
updatedMembers.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
// now should be rolled out
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
"@alice:example.org:DEVICE01",
);
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", () => {
beforeEach(() => {
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
mockTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
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();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE2",
"BBBBBBBBBBB",
4 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
"CCCCCCCCCC",
8 /* KeyId */,
0 /* Timestamp */,
);
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(4);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("AAAAAAAAAAA"),
0,
"@bob:example.org:BOBDEVICE",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("BBBBBBBBBBB"),
4,
"@bob:example.org:BOBDEVICE2",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("CCCCCCCCCC"),
8,
"@carl:example.org:CARLDEVICE",
);
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
});
it("Should support quick re-joiner if keys received out of order", async () => {
jest.useFakeTimers();
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
await jest.advanceTimersByTimeAsync(10);
// Simulate Carl leaving then joining back, and key received out of order
const initialKey0TimeStamp = 1000;
const newKey0TimeStamp = 2000;
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
"BBBBBBBBBBB",
0 /* KeyId */,
newKey0TimeStamp,
);
await jest.advanceTimersByTimeAsync(20);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
initialKey0TimeStamp,
);
await jest.advanceTimersByTimeAsync(20);
// The latest key used for carol should be the one with the latest timestamp
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
decodeBase64("BBBBBBBBBBB"),
0,
"@carol:example.org:CAROLDEVICE",
);
});
it("Should store keys for later retrieval", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
"BBBBBBBBBBB",
0 /* KeyId */,
1000,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
"CCCCCCCCCCC",
5 /* KeyId */,
1000,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE2",
"DDDDDDDDDDD",
0 /* KeyId */,
1000,
);
const knownKeys = encryptionManager.getEncryptionKeys();
// My own key should be there
const myRing = knownKeys.get(getParticipantId("@alice:example.org", "DEVICE01"));
expect(myRing).toBeDefined();
expect(myRing).toHaveLength(1);
expect(myRing![0]).toMatchObject(
expect.objectContaining({
keyIndex: 0,
key: expect.any(Uint8Array),
}),
);
const carlRing = knownKeys.get(getParticipantId("@carl:example.org", "CARLDEVICE"));
expect(carlRing).toBeDefined();
expect(carlRing).toHaveLength(2);
expect(carlRing![0]).toMatchObject(
expect.objectContaining({
keyIndex: 0,
key: decodeBase64("BBBBBBBBBBB"),
}),
);
expect(carlRing![1]).toMatchObject(
expect.objectContaining({
keyIndex: 5,
key: decodeBase64("CCCCCCCCCCC"),
}),
);
const bobRing = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE2"));
expect(bobRing).toBeDefined();
expect(bobRing).toHaveLength(1);
expect(bobRing![0]).toMatchObject(
expect.objectContaining({
keyIndex: 0,
key: decodeBase64("DDDDDDDDDDD"),
}),
);
const bob1Ring = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE"));
expect(bob1Ring).not.toBeDefined();
});
});
it("Should only rotate once again if several membership changes during a rollout", async () => {
jest.useFakeTimers();
let members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
// The initial rollout
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
"@alice:example.org:DEVICE01",
);
onEncryptionKeysChanged.mockClear();
// Trigger a key rotation with a leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
getMembershipMock.mockReturnValue(members);
// This should start a new key rollout
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
// Now simulate a new leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
getMembershipMock.mockReturnValue(members);
// The key `1` rollout is in progress
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
// And another one ( plus a joiner)
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
getMembershipMock.mockReturnValue(lastMembership);
// The key `1` rollout is still in progress
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
// Let all rollouts finish
await jest.advanceTimersByTimeAsync(2000);
// There should 2 rollout. The `1` rollout, then just one additional one
// that has "buffered" the 2 membership changes with leavers
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
"@alice:example.org:DEVICE01",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
2,
"@alice:example.org:DEVICE01",
);
// Key `2` should only be distributed to the last membership
expect(mockTransport.sendKey).toHaveBeenLastCalledWith(
expect.any(String),
2,
// And send only to the last membership
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE3",
membershipTs: 1000,
},
],
);
});
it("Should re-distribute key on transport switch", async () => {
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
const mockToDeviceTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: toDeviceEmitter.on.bind(toDeviceEmitter),
off: toDeviceEmitter.off.bind(toDeviceEmitter),
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
setParentLogger: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
const mockRoomTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: roomEmitter.on.bind(roomEmitter),
off: roomEmitter.off.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
setParentLogger: jest.fn(),
} as unknown as Mocked<RoomKeyTransport>;
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
} as unknown as Mocked<Logger>;
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
transport,
statistics,
onEncryptionKeysChanged,
);
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
// Should have sent the key to the toDevice transport
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
// Simulate receiving a key by room transport
roomEmitter.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
await jest.runOnlyPendingTimersAsync();
// The key should have been re-distributed to the room transport
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key re-distributed
0,
// to all the members
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
});
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
);
}
});
function expectKeyAtIndexToHaveBeenSentTo(
mockTransport: Mocked<ToDeviceKeyTransport>,
index: number,
userId: string,
deviceId: string,
) {
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
index,
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
);
}
function expectKeyAtIndexNotToHaveBeenSentTo(
mockTransport: Mocked<ToDeviceKeyTransport>,
index: number,
userId: string,
deviceId: string,
) {
expect(mockTransport.sendKey).not.toHaveBeenCalledWith(
expect.any(String),
index,
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
);
}
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock";
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks";
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import {
@@ -24,7 +24,7 @@ import {
mockClientMethodsEvents,
mockClientMethodsUser,
} from "../../test-utils/client.ts";
import { type Statistics } from "../../../src/matrixrtc";
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
import { type Logger } from "../../../src/logger.ts";
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
@@ -88,7 +88,9 @@ describe("RoomAndToDeviceTransport", () => {
});
it("only sends to device keys when sending a key", async () => {
transport.start();
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
await transport.sendKey("1235", 0, [
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
]);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
expect(transport.enabled.room).toBeFalsy();
@@ -118,7 +120,9 @@ describe("RoomAndToDeviceTransport", () => {
expect(transport.enabled.room).toBeTruthy();
expect(transport.enabled.toDevice).toBeFalsy();
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
await transport.sendKey("1235", 0, [
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
]);
expect(sendEventMock).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
@@ -132,7 +136,11 @@ describe("RoomAndToDeviceTransport", () => {
});
transport.start();
const membership = mockCallMembership(membershipTemplate, roomId, "@alice:example.org");
const membership: ParticipantDeviceInfo = {
userId: "@alice:example.org",
deviceId: "ALICEDEVICE",
membershipTs: 1234,
};
const onTransportEnabled = jest.fn();
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock";
import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
import { makeMockEvent } from "./mocks";
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
@@ -61,21 +61,9 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
roomId,
"@bob:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
roomId,
"@carl:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
roomId,
"@mat:example.org",
),
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", membershipTs: 1234 },
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1234 },
{ userId: "@mat:example.org", deviceId: "MATDEVICE", membershipTs: 1234 },
]);
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
@@ -100,6 +88,7 @@ describe("ToDeviceKeyTransport", () => {
call_id: "",
scope: "m.room",
},
sent_ts: expect.any(Number),
},
);
@@ -153,11 +142,7 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
roomId,
"@alice:example.org",
),
{ userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
]);
transport.start();
@@ -1,54 +0,0 @@
/*
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.
*/
/*
This file adds a custom test environment for the MembershipManager.spec.ts
It can be used with the comment at the top of the file:
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
in their test name.
*/
import { TestEnvironment } from "jest-environment-jsdom";
import { logger as rootLogger } from "../../../src/logger";
const logger = rootLogger.getChild("[MatrixRTCSession]");
class MemberManagerTestEnvironment extends TestEnvironment {
handleTestEvent(event: any) {
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
let parent = event.test.parent;
let isLegacy = false;
while (parent) {
if (parent.name === "LegacyMembershipManager") {
isLegacy = true;
break;
} else {
parent = parent.parent;
}
}
if (isLegacy) {
logger.info("skip test: ", event.test.name);
event.test.mode = "skip";
}
}
}
}
module.exports = MemberManagerTestEnvironment;
+13 -11
View File
@@ -20,11 +20,12 @@ import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent }
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { secureRandomString } from "../../../src/randomstring";
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
export const membershipTemplate: SessionMembershipData = {
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
application: "m.call",
call_id: "",
user_id: "@mock:user.example",
device_id: "AAAAAAA",
scope: "m.room",
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
@@ -68,7 +69,7 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
}
export function makeMockRoom(
membershipData: MembershipData,
membershipData: MembershipData[],
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
const roomId = secureRandomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
@@ -87,10 +88,8 @@ export function makeMockRoom(
});
}
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
const events = Array.isArray(membershipData)
? membershipData.map((m) => mockRTCEvent(m, roomId))
: [mockRTCEvent(membershipData, roomId)];
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
const events = membershipData.map((m) => mockRTCEvent(m, roomId));
const keysAndEvents = events.map((e) => {
const data = e.getContent() as SessionMembershipData;
return [`_${e.sender?.userId}_${data.device_id}`];
@@ -120,6 +119,10 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
};
}
export function mockRoomState(room: Room, membershipData: MembershipData[]): void {
room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId));
}
export function makeMockEvent(
type: string,
sender: string,
@@ -138,13 +141,12 @@ export function makeMockEvent(
} as unknown as MatrixEvent;
}
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
const sender = customSender ?? "@mock:user.example";
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
}
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData);
}
export function makeKey(id: number, key: string): { key: string; index: number } {
+9 -3
View File
@@ -379,7 +379,9 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.decryptionFailureReason).not.toBe(
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
);
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
@@ -403,7 +405,9 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.decryptionFailureReason).toEqual(
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
);
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.decryptionFailureReason).not.toBe(
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
);
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: DecryptionError: uisi **",
@@ -427,7 +431,9 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeTruthy();
expect(encryptedEvent.decryptionFailureReason).toBe(
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
);
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: DecryptionError: The sender has disabled encrypting to unverified devices. **",
+188
View File
@@ -0,0 +1,188 @@
/*
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 { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
import type { MockedObject } from "jest-mock";
const CREATOR_USER_ID = "@creator:example.org";
const MODERATOR_USER_ID = "@moderator:example.org";
describe("Room", () => {
function createMockClient(): MatrixClient {
return {
supportsThreads: jest.fn().mockReturnValue(true),
decryptEventIfNeeded: jest.fn().mockReturnThis(),
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
} as unknown as MockedObject<MatrixClient>;
}
function createEvent(eventId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
body: eventId, // we do this for ease of use, not practicality
},
event_id: eventId,
sender: CREATOR_USER_ID,
});
}
function createRedaction(redactsEventId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.redaction",
redacts: redactsEventId,
event_id: "$redacts_" + redactsEventId.substring(1),
sender: CREATOR_USER_ID,
});
}
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
return room
.getLiveTimeline()
.getEvents()
.filter((e) => !e.isState());
}
it("should apply redactions locally", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const messageEvent = createEvent("$message_event");
// Set up the room
await room.addLiveEvents([messageEvent], { addToState: false });
let timeline = getNonStateMainTimelineLiveEvents(room);
expect(timeline.length).toEqual(1);
expect(timeline[0].getId()).toEqual(messageEvent.getId());
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
// Now redact
const redactionEvent = createRedaction(messageEvent.getId()!);
await room.addLiveEvents([redactionEvent], { addToState: false });
timeline = getNonStateMainTimelineLiveEvents(room);
expect(timeline.length).toEqual(2);
expect(timeline[0].getId()).toEqual(messageEvent.getId());
expect(timeline[0].isRedacted()).toEqual(true); // test case
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
});
describe("MSC4293: Redact on ban", () => {
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
// Pre-populate room
const messageEvents: MatrixEvent[] = [];
for (let i = 0; i < 3; i++) {
messageEvents.push(createEvent(`$message_${i}`));
}
await room.addLiveEvents(messageEvents, { addToState: false });
if (andGrantPermissions) {
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
return true;
};
}
return { room, messageEvents };
}
function createRedactOnMembershipChange(
targetUserId: string,
senderUserId: string,
membership: string,
): MatrixEvent {
return new MatrixEvent({
type: "m.room.member",
state_key: targetUserId,
content: {
"membership": membership,
"org.matrix.msc4293.redact_events": true,
},
sender: senderUserId,
});
}
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
messageEvents.find((e2) => e2.getId() === e.getId()),
);
expect(actualEvents.length).toEqual(messageEvents.length);
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
if (shouldAllBeRedacted) {
expect(redactedEvents.length).toEqual(messageEvents.length);
} else {
expect(redactedEvents.length).toEqual(0);
}
}
it("should apply on ban", async () => {
const { room, messageEvents } = await setupRoom(true);
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
await room.addLiveEvents([banEvent], { addToState: true });
expectRedacted(messageEvents, room, true);
});
it("should apply on kick", async () => {
const { room, messageEvents } = await setupRoom(true);
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
await room.addLiveEvents([kickEvent], { addToState: true });
expectRedacted(messageEvents, room, true);
});
it("should not apply if the user doesn't have permission to redact", async () => {
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
await room.addLiveEvents([banEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to self-leaves", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to invites", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to joins", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to knocks", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
});
});
+210 -1
View File
@@ -22,7 +22,7 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
import { Direction, EventStatus, EventType, MatrixEvent, RelationType } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
@@ -773,6 +773,215 @@ describe("Thread", () => {
expect(lastEvent).toBe(message1);
expect(firstEvent).toBe(message2);
});
it("Edit events are properly aggregated in threads with server-side support", async () => {
// This test reproduces the race condition bug from https://github.com/element-hq/element-web/issues/30617
// The bug occurs when edits arrive while the thread is not initialized,
// causing aggregation to fail because the target event isn't in the timeline yet
// Given a thread exists with server-side support enabled
const myUserId = "@alice:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("!room:z", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
// Create a root event
const rootEvent = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Root message",
event: true,
});
// Create thread manually - starts with initialEventsFetched = false
const thread = new Thread(rootEvent.getId()!, rootEvent, {
room: room,
client: client,
pendingEventOrdering: PendingEventOrdering.Detached,
});
// The thread is NOT initialized - this is the key to reproducing the bug!
expect(thread.initialEventsFetched).toBe(false);
// Create a message that will be edited
const originalMessage = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Original message in thread",
relatesTo: {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": thread.id,
"m.in_reply_to": {
event_id: thread.id,
},
},
event: true,
});
// Create edit events BEFORE the original message is in the timeline
const edit1 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 1");
const edit2 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 2");
const edit3 = mkEdit(originalMessage, client, myUserId, room.roomId, "Final edit");
// CRITICAL: Add edits while thread is NOT initialized
// They will be queued in replayEvents and aggregation will be attempted but fail
await thread.addEvent(edit1, false);
// Check the aggregation state after adding first edit
// With our fix: edits should NOT be aggregated yet (thread not initialized)
// Without fix: edits would be aggregated but fail to link to target
const relationsAfterFirstEdit = thread.timelineSet.relations?.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Replace,
EventType.RoomMessage,
);
// With the fix, no aggregation happens yet (which is correct)
// Without the fix, aggregation would happen but fail silently
expect(relationsAfterFirstEdit).toBeUndefined();
// Add remaining edits
await thread.addEvent(edit2, false);
await thread.addEvent(edit3, false);
// Check that edits went to replayEvents
expect(thread.replayEvents).toHaveLength(3);
// Now initialize the thread and add the original message
thread.initialEventsFetched = true;
// Clear replayEvents and add the original message
const replayEvents = [...(thread.replayEvents || [])];
thread.replayEvents = [];
// Add original message first
await thread.addEvent(originalMessage, false);
// At this point, the original message should NOT have the edits aggregated yet
// because they were attempted when the target wasn't in timeline
const replacingEventBeforeReplay = originalMessage.replacingEvent();
// With the fix, edits should not be aggregated yet (pre-init)
expect(replacingEventBeforeReplay).toBeNull();
// Then replay the edits
for (const event of replayEvents) {
await thread.addEvent(event, false);
}
// After replay, check aggregation
const replacingEvent = originalMessage.replacingEvent();
// This should now work because edits were re-aggregated when replayed
expect(replacingEvent).toBe(edit3);
// The content should also be updated
expect(originalMessage.getContent().body).toBe("Final edit");
// Relations for replaces should now exist and include all edits in order
const replaceRels = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Replace,
EventType.RoomMessage,
)!;
const replaceIds = replaceRels.getRelations().map((e) => e.getId());
expect(replaceIds).toHaveLength(3);
expect(replaceIds[0]).toBe(edit1.getId());
expect(replaceIds[1]).toBe(edit2.getId());
expect(replaceIds[2]).toBe(edit3.getId());
});
it("Reactions aggregate pre-init and remain idempotent on replay", async () => {
const myUserId = "@alice:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
// Force server-side support so threads start uninitialised
const prevSupport = Thread.hasServerSideSupport;
Thread.setServerSideSupport(FeatureSupport.Stable);
try {
const room = new Room("!room:z", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
// Create a root event and thread
const rootEvent = mkMessage({ room: room.roomId, user: myUserId, msg: "Root", event: true });
const thread = new Thread(rootEvent.getId()!, rootEvent, {
room,
client,
pendingEventOrdering: PendingEventOrdering.Detached,
});
expect(thread.initialEventsFetched).toBe(false);
// A message inside the thread to react to
const originalMessage = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Thread message",
relatesTo: {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": thread.id,
"m.in_reply_to": { event_id: thread.id },
},
event: true,
});
// Create 2 reactions before the message is in the timeline (pre-init)
const reaction1 = mkReaction(originalMessage, client, myUserId, room.roomId);
const reaction2 = mkReaction(originalMessage, client, myUserId, room.roomId);
// Add reactions while thread is NOT initialised
thread.addEvent(reaction1, false);
thread.addEvent(reaction2, false);
// Relations should already include the reactions pre-init
const relsBefore = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
)!;
expect(relsBefore).toBeTruthy();
const beforeIds = new Set(relsBefore.getRelations().map((e) => e.getId()));
expect(beforeIds.size).toBe(2);
// Now initialise and replay
// Ensure reactions are queued for replay as well
expect(thread.replayEvents).toHaveLength(2);
const replay = [...(thread.replayEvents || [])];
thread.replayEvents = [];
thread.initialEventsFetched = true;
// Add the original message first so it becomes findable
thread.addEvent(originalMessage, false);
// Replay reactions
for (const ev of replay) thread.addEvent(ev, false);
// Ensure no duplicates after replay (idempotent aggregation)
const relsAfter = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
)!;
const afterIds = new Set(relsAfter.getRelations().map((e) => e.getId()));
expect(afterIds.size).toBe(2);
expect(afterIds).toEqual(beforeIds);
} finally {
// restore
Thread.setServerSideSupport(prevSupport);
}
});
});
});
});
+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", () => {
+4 -3
View File
@@ -28,6 +28,7 @@ import {
TweakName,
} from "../../src";
import { mockClientMethodsUser } from "../test-utils/client";
import { logger } from "../../src/logger.ts";
const msc3914RoomCallRule: IPushRule = {
rule_id: ".org.matrix.msc3914.rule.room.call",
@@ -209,7 +210,7 @@ describe("NotificationService", function () {
msgtype: "m.text",
},
});
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules!);
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(logger, matrixClient.pushRules!);
pushProcessor = new PushProcessor(matrixClient);
});
@@ -731,7 +732,7 @@ describe("Test PushProcessor.partsForDottedKey", function () {
describe("rewriteDefaultRules", () => {
it("should add default rules in the correct order", () => {
const pushRules = PushProcessor.rewriteDefaultRules({
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
device: {},
global: {
content: [],
@@ -867,7 +868,7 @@ describe("rewriteDefaultRules", () => {
});
it("should add missing msc3914 rule in correct place", () => {
const pushRules = PushProcessor.rewriteDefaultRules({
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
device: {},
global: {
// Sample push rules from a Synapse user.
+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", () => {
@@ -20,6 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { type ServerSideSecretStorage } from "../../../src/secret-storage";
import { logger } from "../../../src/logger.ts";
describe("CrossSigningIdentity", () => {
describe("bootstrapCrossSigning", () => {
@@ -55,7 +56,7 @@ describe("CrossSigningIdentity", () => {
store: jest.fn(),
} as unknown as Mocked<ServerSideSecretStorage>;
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
crossSigning = new CrossSigningIdentity(logger, olmMachine, outgoingRequestProcessor, secretStorage);
});
it("should do nothing if keys are present on-device and in secret storage", async () => {
@@ -53,7 +53,7 @@ describe("KeyClaimManager", () => {
markRequestAsSent: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
const outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
});
@@ -40,6 +40,7 @@ import {
type UIAuthCallback,
} from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { logger } from "../../../src/logger.ts";
describe("OutgoingRequestProcessor", () => {
/** the OutgoingRequestProcessor implementation under test */
@@ -76,7 +77,7 @@ describe("OutgoingRequestProcessor", () => {
markRequestAsSent: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
});
/* simple requests that map directly to the request body */
@@ -293,7 +294,7 @@ describe("OutgoingRequestProcessor", () => {
return await authRequestResultResolvers.promise;
},
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, mockHttpApi);
});
// build a request
@@ -325,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
onlyData: true,
});
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
});
afterEach(() => {
@@ -36,6 +36,7 @@ import {
AllDevicesIsolationMode,
OnlySignedDevicesIsolationMode,
} from "../../../src/crypto-api";
import { logger } from "../../../src/logger.ts";
describe("RoomEncryptor", () => {
describe("History Visibility", () => {
@@ -108,6 +109,7 @@ describe("RoomEncryptor", () => {
} as unknown as Mocked<Room>;
roomEncryptor = new RoomEncryptor(
logger,
mockOlmMachine,
mockKeyClaimManager,
mockOutgoingRequestManager,
+2 -1
View File
@@ -8,6 +8,7 @@ import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/Outgoing
import * as testData from "../../test-utils/test-data";
import * as TestData from "../../test-utils/test-data";
import { RustBackupManager, type KeyBackup } from "../../../src/rust-crypto/backup";
import { logger } from "../../../src/logger.ts";
describe("Upload keys to backup", () => {
/** The backup manager under test */
@@ -63,7 +64,7 @@ describe("Upload keys to backup", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
rustBackupManager = new RustBackupManager(logger, mockOlmMachine, httpAPi, outgoingRequestProcessor);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
});
+118 -48
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import debug from "debug";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import {
BaseMigrationData,
@@ -31,6 +32,7 @@ import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import {
type AccountDataEvents,
DebugLogger,
type Device,
DeviceVerification,
type EmptyObject,
@@ -45,7 +47,7 @@ import {
MemoryCryptoStore,
TypedEventEmitter,
} from "../../../src";
import { emitPromise, mkEvent } from "../../test-utils/test-utils";
import { emitPromise, mkEvent, waitFor } from "../../test-utils/test-utils";
import { type CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { type IEventDecryptionResult, type IMegolmSessionData } from "../../../src/@types/crypto";
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
@@ -68,7 +70,6 @@ import {
import * as testData from "../../test-utils/test-data";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { logger } from "../../../src/logger";
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
import { ClientEvent, type ClientEventHandlerMap } from "../../../src/client";
import { type Curve25519AuthData } from "../../../src/crypto-api/keybackup";
@@ -117,6 +118,7 @@ describe("initRustCrypto", () => {
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: {} as MatrixClient["http"],
@@ -128,8 +130,8 @@ describe("initRustCrypto", () => {
storePassphrase: "storePassphrase",
});
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase");
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase", logger);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
});
it("passes through the store params (key)", async () => {
@@ -140,6 +142,7 @@ describe("initRustCrypto", () => {
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
const storeKey = new Uint8Array(32);
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: {} as MatrixClient["http"],
@@ -151,8 +154,8 @@ describe("initRustCrypto", () => {
storeKey: storeKey,
});
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey, logger);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
});
it("suppresses the storePassphrase and storeKey if storePrefix is unset", async () => {
@@ -162,6 +165,7 @@ describe("initRustCrypto", () => {
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: {} as MatrixClient["http"],
@@ -174,8 +178,8 @@ describe("initRustCrypto", () => {
storePassphrase: "storePassphrase",
});
expect(StoreHandle.open).toHaveBeenCalledWith();
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
expect(StoreHandle.open).toHaveBeenCalledWith(null, null, logger);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
});
it("Should get secrets from inbox on start", async () => {
@@ -186,7 +190,7 @@ describe("initRustCrypto", () => {
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto({
logger,
logger: new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto")),
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
@@ -251,9 +255,10 @@ describe("initRustCrypto", () => {
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
// console.log(`migrated ${progress} of ${total}`);
}
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
@@ -273,6 +278,7 @@ describe("initRustCrypto", () => {
expect.any(BaseMigrationData),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
logger,
);
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
expect(data.pickledAccount).toEqual("not a real account");
@@ -289,6 +295,7 @@ describe("initRustCrypto", () => {
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
logger,
);
// First call should have 50 entries; second should have 10
const sessions1: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[0][0];
@@ -311,6 +318,7 @@ describe("initRustCrypto", () => {
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
logger,
);
// First call should have 50 entries; second should have 10
const megolmSessions1: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
@@ -358,11 +366,11 @@ describe("initRustCrypto", () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
// console.log(`migrated ${progress} of ${total}`);
}
await initRustCrypto({
logger,
logger: new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto")),
http: makeMatrixHttpApi(),
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
@@ -400,6 +408,7 @@ describe("initRustCrypto", () => {
);
const PICKLE_KEY = "pickle1234";
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
@@ -418,6 +427,7 @@ describe("initRustCrypto", () => {
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
logger,
);
const megolmSessions: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[0][0];
@@ -551,11 +561,11 @@ describe("RustCrypto", () => {
const inputs: IToDeviceEvent[] = [
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
const res = (await rustCrypto.preprocessToDeviceMessages(inputs)).map((p) => p.message);
expect(res).toEqual(inputs);
});
it("should pass through bad encrypted messages", async () => {
it("should fail to process bad encrypted messages", async () => {
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const keys = olmMachine.identityKeys;
const inputs: IToDeviceEvent[] = [
@@ -576,7 +586,7 @@ describe("RustCrypto", () => {
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
expect(res.length).toEqual(0);
});
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
@@ -813,7 +823,7 @@ describe("RustCrypto", () => {
} as unknown as OlmMachine;
const rustCrypto = new RustCrypto(
logger,
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
mockOlmMachine,
fetchMock as unknown as MatrixHttpApi<any>,
TEST_USER,
@@ -898,6 +908,7 @@ describe("RustCrypto", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
const logger = new DebugLogger(debug("matrix-js-sdk:test:RustCrypto"));
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
rustCrypto = new RustCrypto(
@@ -957,7 +968,11 @@ describe("RustCrypto", () => {
const outgoingRequestProcessor = {} as unknown as OutgoingRequestProcessor;
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
const outgoingRequestsManager = new OutgoingRequestsManager(
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
olmMachine,
outgoingRequestProcessor,
);
rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager;
// The second time we do a /keys/upload, the `device_keys` property
@@ -1015,7 +1030,7 @@ describe("RustCrypto", () => {
getRoomEventEncryptionInfo: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
logger,
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -1102,6 +1117,7 @@ describe("RustCrypto", () => {
it.each([
[undefined, undefined, null],
["Other", -1, EventShieldReason.UNKNOWN],
[
"Encrypted by an unverified user.",
RustSdkCryptoJs.ShieldStateCode.UnverifiedIdentity,
@@ -1128,6 +1144,11 @@ describe("RustCrypto", () => {
RustSdkCryptoJs.ShieldStateCode.VerificationViolation,
EventShieldReason.VERIFICATION_VIOLATION,
],
[
"Mismatched sender",
RustSdkCryptoJs.ShieldStateCode.MismatchedSender,
EventShieldReason.MISMATCHED_SENDER,
],
])("gets the right shield reason (%s)", async (rustReason, rustCode, expectedReason) => {
// suppress the warning from the unknown shield reason
jest.spyOn(console, "warn").mockImplementation(() => {});
@@ -1228,7 +1249,7 @@ describe("RustCrypto", () => {
getDevice: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
logger,
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -1471,7 +1492,7 @@ describe("RustCrypto", () => {
getIdentity: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
logger,
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -1537,14 +1558,6 @@ describe("RustCrypto", () => {
const e2eKeyReceiver = new E2EKeyReceiver("http://server");
const e2eKeyResponder = new E2EKeyResponder("http://server");
e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver);
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
status: 200,
body: {},
});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
status: 200,
body: {},
});
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
await expect(rustCrypto.pinCurrentUserIdentity(TEST_USER)).rejects.toThrow(
"Cannot pin identity of own user",
@@ -1558,7 +1571,7 @@ describe("RustCrypto", () => {
getIdentity: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
const rustCrypto = new RustCrypto(
logger,
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -1630,7 +1643,7 @@ describe("RustCrypto", () => {
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
const rustCrypto = new RustCrypto(
logger,
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
olmMachine,
makeMatrixHttpApi(),
testData.TEST_USER_ID,
@@ -1782,14 +1795,6 @@ describe("RustCrypto", () => {
error: "Not found",
},
});
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
status: 200,
body: {},
});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
status: 200,
body: {},
});
const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
// dehydration requires secret storage and cross signing
@@ -1923,14 +1928,6 @@ describe("RustCrypto", () => {
error: "Not found",
},
});
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
status: 200,
body: {},
});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
status: 200,
body: {},
});
rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
// dehydration requires secret storage and cross signing
@@ -2291,8 +2288,9 @@ describe("RustCrypto", () => {
});
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
// We have a key backup
expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull();
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
const authUploadDeviceSigningKeys = jest.fn();
await rustCrypto.resetEncryption(authUploadDeviceSigningKeys);
@@ -2348,6 +2346,78 @@ describe("RustCrypto", () => {
expect(dehydratedDeviceIsDeleted).toBeTruthy();
});
});
describe("maybeAcceptKeyBundle", () => {
let mockOlmMachine: Mocked<OlmMachine>;
let rustCrypto: RustCrypto;
beforeEach(async () => {
mockOlmMachine = {
queryKeysForUsers: jest.fn().mockReturnValue({}),
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.queryKeysForUsers).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.getReceivedRoomKeyBundleData).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][0].toString()).toEqual("!room_id");
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][1].toString()).toEqual("@bob:example.org");
expect(mockOlmMachine.receiveRoomKeyBundle).not.toHaveBeenCalled();
});
it("fetches the bundle via http and throws an error on failure", async () => {
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
await expect(() => rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org")).rejects.toMatchObject({
errcode: "M_NOT_FOUND",
httpStatus: 404,
});
});
it("fetches the bundle via http and passes it back into the OlmMachine", async () => {
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
body: "asdfghjkl",
});
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
});
});
});
/** Build a MatrixHttpApi instance */
@@ -2371,7 +2441,7 @@ async function makeTestRustCrypto(
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto({
logger,
logger: new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec")),
http,
userId,
deviceId,
@@ -156,6 +156,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -289,6 +291,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -405,6 +409,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -460,6 +466,15 @@ describe("VerificationRequest", () => {
});
});
/**
* Needed until https://github.com/matrix-org/matrix-rust-sdk/issues/5643 is fixed.
*
* Modify the content of the supplied content to include `msgtype: m.key.verification.request`.
*/
function todoFixupVerificationRequestContent(content: any) {
content.msgtype = "m.key.verification.request";
}
describe("isVerificationEvent", () => {
it.each([
[EventType.KeyVerificationCancel],
+43
View File
@@ -571,6 +571,49 @@ describe("SyncAccumulator", function () {
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
});
it("should delete knock state when room transitions from knock to join", () => {
const initKnockState = makeKnockState();
sa.accumulate(
syncSkeleton(
{},
{},
{},
{
knock_state: initKnockState,
},
),
);
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
// Room transitions from knock to join (e.g., after approval and joining)
const joinState = {
account_data: { events: [] },
ephemeral: { events: [] },
unread_notifications: {},
state: {
events: [member("bob", KnownMembership.Join)],
},
};
const syncResponse = {
next_batch: "abc",
rooms: {
join: {
"!knock:bar": joinState,
},
invite: {},
leave: {},
},
} as unknown as ISyncResponse;
sa.accumulate(syncResponse);
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
expect(sa.getJSON().roomsData.join["!knock:bar"].state?.events[0]?.content.membership).toEqual(
KnownMembership.Join,
);
});
it("should accumulate read receipts", () => {
const receipt1 = {
type: "m.receipt",
+10 -1
View File
@@ -51,7 +51,12 @@ import {
type SDPStreamMetadata,
type SDPStreamMetadataKey,
} from "../webrtc/callEventTypes.ts";
import { type EncryptionKeysEventContent, type ICallNotifyContent } from "../matrixrtc/types.ts";
import {
type IRTCNotificationContent,
type IRTCDeclineContent,
type EncryptionKeysEventContent,
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
@@ -147,6 +152,8 @@ export enum EventType {
// MatrixRTC events
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline",
}
export enum RelationType {
@@ -325,6 +332,8 @@ export interface TimelineEvents {
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
[EventType.RTCDecline]: IRTCDeclineContent;
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
+25 -7
View File
@@ -27,13 +27,6 @@ import { type EventType, type RelationType, type RoomType } from "./event.ts";
/* eslint-disable camelcase */
export interface IJoinRoomOpts {
/**
* True to do a room initial sync on the resulting
* room. If false, the <strong>returned Room object will have no current state.
* </strong> Default: true.
*/
syncRoom?: boolean;
/**
* If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
*/
@@ -43,6 +36,31 @@ export interface IJoinRoomOpts {
* The server names to try and join through in addition to those that are automatically chosen.
*/
viaServers?: string[];
/**
* When accepting an invite, whether to accept encrypted history shared by the inviter via the experimental
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
*
* @experimental
*/
acceptSharedHistory?: boolean;
}
/** Options object for {@link MatrixClient.invite}. */
export interface InviteOpts {
/**
* The reason for the invite.
*/
reason?: string;
/**
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
* visibility was `shared`, via the experimental
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
*
* @experimental
*/
shareEncryptedHistory?: boolean;
}
export interface KnockRoomOpts {
+9 -2
View File
@@ -47,12 +47,19 @@ export const M_TOPIC = new NamespacedValue("m.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = IMessageRendering[];
export type MTopicContent = { "m.text": IMessageRendering[] };
/**
* A previous incorrect form of m.topic used by matrix-js-sdk
* TODO remove this after a few releases
* https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
*/
export type MalformedMTopicEvent = { "m.topic": IMessageRendering[] };
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = { "m.topic": MTopicContent };
export type MTopicEvent = { "m.topic": MTopicContent } | MalformedMTopicEvent;
/**
* The event content for an m.room.topic event
-6
View File
@@ -22,9 +22,3 @@ import { type AuthDict } from "../interactive-auth.ts";
export type UIARequest<T> = T & {
auth?: AuthDict;
};
/**
* Helper type to represent HTTP response body for a UIA enabled endpoint
* @deprecated - a successful response for a UIA enabled endpoint is no different, UIA is signalled via an error
*/
export type UIAResponse<T> = T;
+1 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk/lib/types";
import { type Optional } from "matrix-events-sdk";
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
+13 -10
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { ToDeviceMessageId } from "./@types/event.ts";
import { logger } from "./logger.ts";
import { type Logger } from "./logger.ts";
import { type MatrixClient, ClientEvent } from "./client.ts";
import { type MatrixError } from "./http-api/index.ts";
import {
@@ -40,7 +40,10 @@ export class ToDeviceMessageQueue {
private retryTimeout: ReturnType<typeof setTimeout> | null = null;
private retryAttempts = 0;
public constructor(private client: MatrixClient) {}
public constructor(
private client: MatrixClient,
private readonly logger: Logger,
) {}
public start(): void {
this.running = true;
@@ -67,7 +70,7 @@ export class ToDeviceMessageQueue {
const msgmap = batchWithTxnId.batch.map(
(msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
);
logger.info(
this.logger.info(
`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
msgmap,
);
@@ -83,7 +86,7 @@ export class ToDeviceMessageQueue {
if (this.sending || !this.running) return;
logger.debug("Attempting to send queued to-device messages");
this.logger.debug("Attempting to send queued to-device messages");
this.sending = true;
let headBatch: IndexedToDeviceBatch | null;
@@ -99,7 +102,7 @@ export class ToDeviceMessageQueue {
// Make sure we're still running after the async tasks: if not, stop.
if (!this.running) return;
logger.debug("All queued to-device messages sent");
this.logger.debug("All queued to-device messages sent");
} catch (e) {
++this.retryAttempts;
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -109,15 +112,15 @@ export class ToDeviceMessageQueue {
// the scheduler function doesn't differentiate between fatal errors and just getting
// bored and giving up for now
if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
this.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
await this.client.store.removeToDeviceBatch(headBatch!.id);
} else {
logger.info("Automatic retry limit reached for to-device messages.");
this.logger.info("Automatic retry limit reached for to-device messages.");
}
return;
}
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
} finally {
this.sending = false;
@@ -133,7 +136,7 @@ export class ToDeviceMessageQueue {
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
}
logger.info(
this.logger.info(
`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
);
@@ -146,7 +149,7 @@ export class ToDeviceMessageQueue {
*/
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
logger.info(`Resuming queue after resumed sync`);
this.logger.info(`Resuming queue after resumed sync`);
this.sendQueue();
}
};
+190 -163
View File
@@ -87,7 +87,12 @@ import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.t
import { type MatrixScheduler } from "./scheduler.ts";
import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts";
import { type AuthDict } from "./interactive-auth.ts";
import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
import {
type IMinimalEvent,
type IRoomEvent,
type IStateEvent,
type ReceivedToDeviceMessage,
} from "./sync-accumulator.ts";
import type { EventTimelineSet } from "./models/event-timeline-set.ts";
import * as ContentHelpers from "./content-helpers.ts";
import {
@@ -110,6 +115,7 @@ import {
type IGuestAccessOpts,
type IJoinRoomOpts,
type INotificationsResponse,
type InviteOpts,
type IPaginateOpts,
type IPresenceOpts,
type IRedactOpts,
@@ -217,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,
@@ -360,7 +366,7 @@ export interface ICreateClientOpts {
* to all requests with this client. Useful for application services which require
* `?user_id=`.
*/
queryParams?: Record<string, string>;
queryParams?: QueryDict;
/**
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
@@ -434,13 +440,13 @@ export interface ICreateClientOpts {
/**
* If true, group calls will not establish media connectivity and only create the signaling events,
* so that livekit media can be used in the application layert (js-sdk contains no livekit code).
* so that livekit media can be used in the application layer (js-sdk contains no livekit code).
*/
useLivekitForGroupCalls?: boolean;
/**
* A logger to associate with this MatrixClient.
* Defaults to the built-in global logger.
* Defaults to the built-in global logger; see {@link DebugLogger} for an alternative.
*/
logger?: Logger;
}
@@ -536,6 +542,7 @@ export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_m
export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140";
export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133";
export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable";
enum CrossSigningKeyType {
MasterKey = "master_key",
@@ -883,76 +890,6 @@ interface IWhoamiResponse {
const EVENT_ID_PREFIX = "$";
export enum ClientEvent {
Sync = "sync",
Event = "event",
ToDeviceEvent = "toDeviceEvent",
AccountData = "accountData",
Room = "Room",
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
type RoomEvents =
| RoomEvent.Name
| RoomEvent.Redaction
| RoomEvent.RedactionCancelled
| RoomEvent.Receipt
| RoomEvent.Tags
| RoomEvent.LocalEchoUpdated
| RoomEvent.HistoryImportedWithinTimeline
| RoomEvent.AccountData
| RoomEvent.MyMembership
| RoomEvent.Timeline
| RoomEvent.TimelineReset;
type RoomStateEvents =
| RoomStateEvent.Events
| RoomStateEvent.Members
| RoomStateEvent.NewMember
| RoomStateEvent.Update
| RoomStateEvent.Marker;
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
type RoomMemberEvents =
| RoomMemberEvent.Name
| RoomMemberEvent.Typing
| RoomMemberEvent.PowerLevel
| RoomMemberEvent.Membership;
type UserEvents =
| UserEvent.AvatarUrl
| UserEvent.DisplayName
| UserEvent.Presence
| UserEvent.CurrentlyActive
| UserEvent.LastPresenceTs;
export type EmittedEvents =
| ClientEvent
| RoomEvents
| RoomStateEvents
| CryptoEvents
| MatrixEventEvents
| RoomMemberEvents
| UserEvents
| CallEvent // re-emitted by call.ts using Object.values
| CallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Outgoing
| GroupCallEventHandlerEvent.Ended
| GroupCallEventHandlerEvent.Participants
| HttpApiEvent.SessionLoggedOut
| HttpApiEvent.NoConsent
| BeaconEvent;
export type ClientEventHandlerMap = {
/**
* Fires whenever the SDK's syncing state is updated. The state can be one of:
* <ul>
@@ -1035,13 +972,15 @@ export type ClientEventHandlerMap = {
* trying to sync after stopClient has been called.</li>
* </ul>
*
* @param state - An enum representing the syncing state. One of "PREPARED",
* The payloads consits of the following 3 parameters:
*
* - state - An enum representing the syncing state. One of "PREPARED",
* "SYNCING", "ERROR", "STOPPED".
*
* @param prevState - An enum representing the previous syncing state.
* - prevState - An enum representing the previous syncing state.
* One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
*
* @param data - Data about this transition.
* - data - Data about this transition.
*
* @example
* ```
@@ -1061,14 +1000,14 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
Sync = "sync",
/**
* Fires whenever the SDK receives a new event.
* <p>
* This is only fired for live events received via /sync - it is not fired for
* events received over context, search, or pagination APIs.
*
* @param event - The matrix event which caused this event to fire.
* The payload is the matrix event which caused this event to fire.
* @example
* ```
* matrixClient.on("event", function(event){
@@ -1076,10 +1015,10 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.Event]: (event: MatrixEvent) => void;
/**
Event = "event",
/** @deprecated Use {@link ReceivedToDeviceMessage}.
* Fires whenever the SDK receives a new to-device event.
* @param event - The matrix event which caused this event to fire.
* The payload is the matrix event ({@link MatrixEvent}) which caused this event to fire.
* @example
* ```
* matrixClient.on("toDeviceEvent", function(event){
@@ -1087,23 +1026,25 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
ToDeviceEvent = "toDeviceEvent",
/**
* Fires if a to-device event is received that cannot be decrypted.
* Encrypted to-device events will (generally) use plain Olm encryption,
* in which case decryption failures are fatal: the event will never be
* decryptable, unlike Megolm encrypted events where the key may simply
* arrive later.
*
* An undecryptable to-device event is therefore likely to indicate problems.
*
* @param event - The undecyptable to-device event
* Fires whenever the SDK receives a new (potentially decrypted) to-device message.
* The payload is the to-device message and the encryption info for that message ({@link ReceivedToDeviceMessage}).
* @example
* ```
* matrixClient.on("receivedToDeviceMessage", function(payload){
* const { message, encryptionInfo } = payload;
* var claimed_sender = encryptionInfo ? encryptionInfo.sender : message.sender;
* var isVerified = encryptionInfo ? encryptionInfo.verified : false;
* var type = message.type;
* });
*/
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
ReceivedToDeviceMessage = "receivedToDeviceMessage",
/**
* Fires whenever new user-scoped account_data is added.
* @param event - The event describing the account_data just added
* @param event - The previous account data, if known.
* The payload is a pair of event ({@link MatrixEvent}) describing the account_data just added, and the previous event, if known:
* - event: The event describing the account_data just added
* - oldEvent: The previous account data, if known.
* @example
* ```
* matrixClient.on("accountData", function(event, oldEvent){
@@ -1111,12 +1052,13 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
AccountData = "accountData",
/**
* Fires whenever a new Room is added. This will fire when you are invited to a
* room, as well as when you join a room. <strong>This event is experimental and
* may change.</strong>
* @param room - The newly created, fully populated room.
*
* The payload is the newly created room, fully populated.
* @example
* ```
* matrixClient.on("Room", function(room){
@@ -1124,11 +1066,11 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.Room]: (room: Room) => void;
Room = "Room",
/**
* Fires whenever a Room is removed. This will fire when you forget a room.
* <strong>This event is experimental and may change.</strong>
* @param roomId - The deleted room ID.
* The payload is the roomId of the deleted room.
* @example
* ```
* matrixClient.on("deleteRoom", function(roomId){
@@ -1136,13 +1078,82 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.DeleteRoom]: (roomId: string) => void;
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
/**
* Fires when the client .well-known info is fetched.
*
* @param data - The JSON object returned by the server
* The payload is the JSON object (see {@link IClientWellKnown}) returned by the server
*/
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
type RoomEvents =
| RoomEvent.Name
| RoomEvent.Redaction
| RoomEvent.RedactionCancelled
| RoomEvent.Receipt
| RoomEvent.Tags
| RoomEvent.LocalEchoUpdated
| RoomEvent.HistoryImportedWithinTimeline
| RoomEvent.AccountData
| RoomEvent.MyMembership
| RoomEvent.Timeline
| RoomEvent.TimelineReset;
type RoomStateEvents =
| RoomStateEvent.Events
| RoomStateEvent.Members
| RoomStateEvent.NewMember
| RoomStateEvent.Update
| RoomStateEvent.Marker;
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
type RoomMemberEvents =
| RoomMemberEvent.Name
| RoomMemberEvent.Typing
| RoomMemberEvent.PowerLevel
| RoomMemberEvent.Membership;
type UserEvents =
| UserEvent.AvatarUrl
| UserEvent.DisplayName
| UserEvent.Presence
| UserEvent.CurrentlyActive
| UserEvent.LastPresenceTs;
export type EmittedEvents =
| ClientEvent
| RoomEvents
| RoomStateEvents
| CryptoEvents
| MatrixEventEvents
| RoomMemberEvents
| UserEvents
| CallEvent // re-emitted by call.ts using Object.values
| CallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Outgoing
| GroupCallEventHandlerEvent.Ended
| GroupCallEventHandlerEvent.Participants
| HttpApiEvent.SessionLoggedOut
| HttpApiEvent.NoConsent
| BeaconEvent;
export type ClientEventHandlerMap = {
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
[ClientEvent.Event]: (event: MatrixEvent) => void;
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
[ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void;
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
[ClientEvent.Room]: (room: Room) => void;
[ClientEvent.DeleteRoom]: (roomId: string) => void;
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
@@ -1341,9 +1352,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// NB. We initialise MatrixRTC whether we have call support or not: this is just
// the underlying session management and doesn't use any actual media capabilities
this.matrixRTC = new MatrixRTCSessionManager(this);
this.matrixRTC = new MatrixRTCSessionManager(this.logger, this);
this.serverCapabilitiesService = new ServerCapabilities(this.http);
this.serverCapabilitiesService = new ServerCapabilities(this.logger, this.http);
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
@@ -1365,7 +1376,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.roomNameGenerator = opts.roomNameGenerator;
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this, this.logger);
// The SDK doesn't really provide a clean way for events to recalculate the push
// actions for themselves, so we have to kinda help them out when they are encrypted.
@@ -1482,6 +1493,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
return this.canResetTimelineCallback(roomId);
},
logger: this.logger.getChild("sync"),
};
}
@@ -1527,9 +1539,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Clear any data out of the persistent stores used by the client.
*
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
* @returns Promise which resolves when the stores have been cleared.
*/
public clearStores(): Promise<void> {
public clearStores(
args: {
cryptoDatabasePrefix?: string;
} = {},
): Promise<void> {
if (this.clientRunning) {
throw new Error("Cannot clear stores while client is running");
}
@@ -1552,8 +1569,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return;
}
for (const dbname of [
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
]) {
const prom = new Promise((resolve, reject) => {
this.logger.info(`Removing IndexedDB instance ${dbname}`);
@@ -1901,6 +1918,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* ensuring that only one `MatrixClient` issue is instantiated at a time.
*
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
* Unused if useIndexedDB is 'false'.
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
@@ -1914,6 +1933,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async initRustCrypto(
args: {
useIndexedDB?: boolean;
cryptoDatabasePrefix?: string;
storageKey?: Uint8Array;
storagePassword?: string;
} = {},
@@ -1950,7 +1970,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
deviceId: deviceId,
secretStorage: this.secretStorage,
cryptoCallbacks: this.cryptoCallbacks,
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
storePrefix: args.useIndexedDB === false ? null : (args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX),
storeKey: args.storageKey,
storePassphrase: args.storagePassword,
@@ -2121,21 +2141,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] {
const allRooms = this.store.getRooms();
const replacedRooms = new Set();
for (const r of allRooms) {
const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
if (predecessor) {
replacedRooms.add(predecessor);
const visibleRooms = new Set(allRooms);
for (const room of visibleRooms) {
const predecessors = this.findPredecessorRooms(room, true, msc3946ProcessDynamicPredecessor);
for (const predecessor of predecessors) {
visibleRooms.delete(predecessor);
}
}
return allRooms.filter((r) => {
const tombstone = r.currentState.getStateEvents(EventType.RoomTombstone, "");
if (tombstone && replacedRooms.has(r.roomId)) {
return false;
}
return true;
});
return Array.from(visibleRooms);
}
/**
@@ -2176,7 +2189,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
): Promise<EmptyObject> {
// If the sync loop is not running, fall back to setAccountDataRaw.
if (!this.clientRunning) {
logger.warn(
this.logger.warn(
"Calling `setAccountData` before the client is started: `getAccountData` may return inconsistent results.",
);
return await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
@@ -2336,12 +2349,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Rejects: with an error response.
*/
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
if (opts.syncRoom === undefined) {
opts.syncRoom = true;
}
const room = this.getRoom(roomIdOrAlias);
if (room?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return room;
const roomMember = room?.getMember(this.getSafeUserId());
const preJoinMembership = roomMember?.membership;
// If we were invited to the room, the ID of the user that sent the invite. Otherwise, `null`.
const inviter =
preJoinMembership == KnownMembership.Invite ? (roomMember?.events.member?.getSender() ?? null) : null;
this.logger.debug(
`joinRoom[${roomIdOrAlias}]: preJoinMembership=${preJoinMembership}, inviter=${inviter}, opts=${JSON.stringify(opts)}`,
);
if (preJoinMembership == KnownMembership.Join) return room!;
let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
@@ -2368,6 +2387,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.
@@ -2375,12 +2398,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (resolvedRoom?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return resolvedRoom;
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const syncRoom = syncApi.createRoom(roomId);
if (opts.syncRoom) {
// v2 will do this for us
// return syncApi.syncRoom(room);
}
return syncRoom;
return syncApi.createRoom(roomId);
}
/**
@@ -3565,6 +3583,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
}
public sendRtcDecline(roomId: string, notificationEventId: string): Promise<ISendEventResponse> {
return this.sendEvent(roomId, EventType.RTCDecline, {
"m.relates_to": { event_id: notificationEventId, rel_type: RelationType.Reference },
});
}
/**
* Get a preview of the given URL as of (roughly) the given point in time,
* described as an object with OpenGraph keys and associated values.
@@ -3731,12 +3755,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* @param reason - Optional.
* @returns Promise which resolves: `{}` an empty object.
* @returns Rejects: with an error response.
* Send an invite to the given user to join the given room.
*
* @param roomId - The ID of the room to which the user should be invited.
* @param userId - The ID of the user that should be invited.
* @param opts - Optional reason object. For backwards compatibility, a string is also accepted, and will be interpreted as a reason.
*
* @returns An empty object.
*/
public invite(roomId: string, userId: string, reason?: string): Promise<EmptyObject> {
return this.membershipChange(roomId, userId, KnownMembership.Invite, reason);
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
if (typeof opts != "object") {
opts = { reason: opts };
}
if (opts.shareEncryptedHistory) {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
}
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
}
/**
@@ -3809,7 +3845,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId: string,
includeFuture = true,
): Promise<{ [roomId: string]: Error | MatrixError | null }> {
const upgradeHistory = this.getRoomUpgradeHistory(roomId);
const upgradeHistory = this.getRoomUpgradeHistory(roomId, true);
let eligibleToLeave = upgradeHistory;
if (!includeFuture) {
@@ -6811,9 +6847,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param opts - options object
*
* @returns Promise which resolves to response object, as
* determined by this.opts.onlyData, opts.rawResponse, and
* opts.onlyContentUri. Rejects with an error (usually a MatrixError).
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
*/
public uploadContent(file: FileType, opts?: UploadOpts): Promise<UploadResponse> {
return this.http.uploadContent(file, opts);
@@ -6863,7 +6897,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns `true` if supported, otherwise `false`
*/
public async doesServerSupportExtendedProfiles(): Promise<boolean> {
return this.doesServerSupportUnstableFeature(UNSTABLE_MSC4133_EXTENDED_PROFILES);
return (
(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4133_EXTENDED_PROFILES)) ||
(await this.doesServerSupportUnstableFeature(STABLE_MSC4133_EXTENDED_PROFILES))
);
}
/**
@@ -7295,7 +7332,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setPushRules(rules: IPushRules): void {
// Fix-up defaults, if applicable.
this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
this.pushRules = PushProcessor.rewriteDefaultRules(this.logger, rules, this.getUserId()!);
// Pre-calculate any necessary caches.
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
}
@@ -8366,21 +8403,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
/**
* Get the OIDC issuer responsible for authentication on this server, if any
* @returns Resolves: A promise of an object containing the OIDC issuer if configured
* @returns Rejects: when the request fails (module:http-api.MatrixError)
* @experimental - part of MSC2965
* @deprecated in favour of getAuthMetadata
*/
public async getAuthIssuer(): Promise<{
issuer: string;
}> {
return this.http.request(Method.Get, "/auth_issuer", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
});
}
/**
* Discover and validate delegated auth configuration
* - delegated auth issuer openid-configuration is reachable
@@ -8400,7 +8422,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
const { issuer } = await this.getAuthIssuer();
// Fall back to older variant of MSC2965
const { issuer } = await this.http.request<{
issuer: string;
}>(Method.Get, "/auth_issuer", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
});
return discoverAndValidateOIDCIssuerWellKnown(issuer);
}
throw e;
+18 -3
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts";
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
@@ -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
@@ -96,9 +109,11 @@ export interface SyncCryptoCallbacks {
* messages, rather than the results of any decryption attempts.
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
* @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or
* failed to decrypt, and so will be omitted from the output list.
*
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]>;
/**
* Called by the /sync loop when one time key counts and unused fallback key details are received.
+6 -2
View File
@@ -197,7 +197,7 @@ export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
if (isProvided(topic)) {
renderings.push({ body: topic, mimetype: "text/plain" });
}
return { topic, [M_TOPIC.name]: renderings };
return { topic, [M_TOPIC.name]: { "m.text": renderings } };
};
export type TopicState = {
@@ -206,7 +206,11 @@ export type TopicState = {
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
const mtopicParent = M_TOPIC.findIn<MTopicContent>(content);
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
//const mtopic = M_TOPIC.findIn<MTopicContent>(content)?.["m.text"];
if (!Array.isArray(mtopic)) {
return { text: content.topic ?? undefined };
}
+41 -107
View File
@@ -468,16 +468,6 @@ export interface CryptoApi {
*/
getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[];
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
* @deprecated prefer `userId` parameter variant.
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Finds a DM verification request that is already in progress for the given room and user.
*
@@ -545,18 +535,6 @@ export interface CryptoApi {
*/
getSessionBackupPrivateKey(): Promise<Uint8Array | null>;
/**
* Store the backup decryption key.
*
* This should be called if the client has received the key from another device via secret sharing (gossiping).
* It is the responsability of the caller to check that the decryption key is valid for the current backup version.
*
* @param key - the backup decryption key
*
* @deprecated prefer the variant with a `version` parameter.
*/
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
/**
* Store the backup decryption key.
*
@@ -729,6 +707,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. */
@@ -787,45 +779,6 @@ export enum DecryptionFailureCode {
/** Unknown or unclassified error. */
UNKNOWN_ERROR = "UNKNOWN_ERROR",
/** @deprecated only used in legacy crypto */
MEGOLM_BAD_ROOM = "MEGOLM_BAD_ROOM",
/** @deprecated only used in legacy crypto */
MEGOLM_MISSING_FIELDS = "MEGOLM_MISSING_FIELDS",
/** @deprecated only used in legacy crypto */
OLM_DECRYPT_GROUP_MESSAGE_ERROR = "OLM_DECRYPT_GROUP_MESSAGE_ERROR",
/** @deprecated only used in legacy crypto */
OLM_BAD_ENCRYPTED_MESSAGE = "OLM_BAD_ENCRYPTED_MESSAGE",
/** @deprecated only used in legacy crypto */
OLM_BAD_RECIPIENT = "OLM_BAD_RECIPIENT",
/** @deprecated only used in legacy crypto */
OLM_BAD_RECIPIENT_KEY = "OLM_BAD_RECIPIENT_KEY",
/** @deprecated only used in legacy crypto */
OLM_BAD_ROOM = "OLM_BAD_ROOM",
/** @deprecated only used in legacy crypto */
OLM_BAD_SENDER_CHECK_FAILED = "OLM_BAD_SENDER_CHECK_FAILED",
/** @deprecated only used in legacy crypto */
OLM_BAD_SENDER = "OLM_BAD_SENDER",
/** @deprecated only used in legacy crypto */
OLM_FORWARDED_MESSAGE = "OLM_FORWARDED_MESSAGE",
/** @deprecated only used in legacy crypto */
OLM_MISSING_CIPHERTEXT = "OLM_MISSING_CIPHERTEXT",
/** @deprecated only used in legacy crypto */
OLM_NOT_INCLUDED_IN_RECIPIENTS = "OLM_NOT_INCLUDED_IN_RECIPIENTS",
/** @deprecated only used in legacy crypto */
UNKNOWN_ENCRYPTION_ALGORITHM = "UNKNOWN_ENCRYPTION_ALGORITHM",
}
/** Base {@link DeviceIsolationMode} kind. */
@@ -1090,8 +1043,6 @@ export type ImportRoomKeyProgressData = ImportRoomKeyFetchProgress | ImportRoomK
export interface ImportRoomKeysOpts {
/** Reports ongoing progress of the import process. Can be used for feedback. */
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
/** @deprecated the rust SDK will always such imported keys as untrusted */
untrusted?: boolean;
/** @deprecated not useful externally */
source?: string;
}
@@ -1179,13 +1130,6 @@ export interface CryptoCallbacks {
name: string,
) => Promise<[string, Uint8Array] | null>;
/** @deprecated: unused with the Rust crypto stack. */
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
/** @deprecated: unused with the Rust crypto stack. */
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
/** @deprecated: unused with the Rust crypto stack. */
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
/**
* Called by {@link CryptoApi.bootstrapSecretStorage} when a new default secret storage key is created.
*
@@ -1197,24 +1141,6 @@ export interface CryptoCallbacks {
* @param key - private key to store
*/
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
/** @deprecated: unused with the Rust crypto stack. */
onSecretRequested?: (
userId: string,
deviceId: string,
requestId: string,
secretName: string,
deviceTrust: DeviceVerificationStatus,
) => Promise<string | undefined>;
/** @deprecated: unused with the Rust crypto stack. */
getDehydrationKey?: (
keyInfo: SecretStorageKeyDescription,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
/** @deprecated: unused with the Rust crypto stack. */
getBackupKey?: () => Promise<Uint8Array>;
}
/**
@@ -1229,13 +1155,6 @@ export interface CreateSecretStorageOpts {
*/
createSecretStorageKey?: () => Promise<GeneratedSecretStorageKey>;
/**
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
* @deprecated Not used by the Rust crypto stack.
*/
keyBackupInfo?: KeyBackupInfo;
/**
* If true, a new key backup version will be
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
@@ -1247,18 +1166,6 @@ export interface CreateSecretStorageOpts {
* Reset even if keys already exist.
*/
setupNewSecretStorage?: boolean;
/**
* Function called to get the user's current key backup passphrase.
*
* Should return a promise that resolves with a Uint8Array
* containing the key, or rejects if the key cannot be obtained.
*
* Only used when the client has existing key backup, but no secret storage.
*
* @deprecated Not used by the Rust crypto stack.
*/
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
/** Types of cross-signing key */
@@ -1355,6 +1262,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} */
@@ -1365,6 +1278,27 @@ export interface OwnDeviceKeys {
curve25519: string;
}
/**
* Information about the encryption of a successfully decrypted to-device message.
*/
export interface OlmEncryptionInfo {
/** The user ID of the event sender, note this is untrusted data unless `isVerified` is true **/
sender: string;
/**
* The device ID of the device that sent us the event.
* Note this is untrusted data unless {@link senderVerified} is true.
* If the device ID is not known, this will be `null`.
**/
senderDevice?: string;
/** The sender device's public Curve25519 key, base64 encoded **/
senderCurve25519KeyBase64: string;
/**
* If true, this message is guaranteed to be authentic as it is coming from a device belonging to a user that we have verified.
* This is the state at the time of decryption (the user could be verified later).
*/
senderVerified: boolean;
}
export * from "./verification.ts";
export type * from "./keybackup.ts";
export * from "./recovery-key.ts";
-28
View File
@@ -114,25 +114,6 @@ export interface VerificationRequest
*/
cancel(params?: { reason?: string; code?: string }): Promise<void>;
/**
* Create a {@link Verifier} to do this verification via a particular method.
*
* If a verifier has already been created for this request, returns that verifier.
*
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Verifier.verify} on the
* returned verifier.
*
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
*
* @param method - the name of the verification method to use.
* @param targetDevice - details of where to send the request to.
*
* @returns The verifier which will do the actual verification.
*
* @deprecated Use {@link VerificationRequest#startVerification} instead.
*/
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
/**
* Send an `m.key.verification.start` event to start verification via a particular method.
*
@@ -163,15 +144,6 @@ export interface VerificationRequest
*/
get verifier(): Verifier | undefined;
/**
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*
* @deprecated Not supported in Rust Crypto. Use {@link VerificationRequest#generateQRCode} instead.
*/
getQRCodeBytes(): Uint8ClampedArray | undefined;
/**
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
*
-3
View File
@@ -25,9 +25,6 @@ export interface MapperOpts {
preventReEmit?: boolean;
// decrypt event proactively
decrypt?: boolean;
/** @deprecated no longer used */
toDevice?: boolean;
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
+13 -11
View File
@@ -99,17 +99,19 @@ export class FilterComponent {
* Converts the filter component into the form expected over the wire
*/
public toJSON(): object {
return {
types: this.filterJson.types || null,
not_types: this.filterJson.not_types || [],
rooms: this.filterJson.rooms || null,
not_rooms: this.filterJson.not_rooms || [],
senders: this.filterJson.senders || null,
not_senders: this.filterJson.not_senders || [],
contains_url: this.filterJson.contains_url || null,
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [],
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [],
};
return Object.fromEntries(
Object.entries({
types: this.filterJson.types,
not_types: this.filterJson.not_types,
rooms: this.filterJson.rooms,
not_rooms: this.filterJson.not_rooms,
senders: this.filterJson.senders,
not_senders: this.filterJson.not_senders,
contains_url: this.filterJson.contains_url,
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name],
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name],
}).filter(([_key, value]) => value),
);
}
/**
+41 -58
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,
@@ -33,16 +34,6 @@ import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
import { type QueryDict } from "../utils.ts";
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
interface TypedResponse<T> extends Response {
json(): Promise<T>;
}
export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
? string
: O extends { onlyData: true } | undefined
? T
: TypedResponse<T>;
export class FetchHttpApi<O extends IHttpOpts> {
private abortController = new AbortController();
private readonly tokenRefresher: TokenRefresher;
@@ -52,7 +43,9 @@ export class FetchHttpApi<O extends IHttpOpts> {
public readonly opts: O,
) {
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
opts.onlyData = !!opts.onlyData;
if (!opts.onlyData) {
throw new Error("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
}
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
this.tokenRefresher = new TokenRefresher(opts);
@@ -84,7 +77,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
params: Record<string, string | string[]> | undefined,
prefix: string,
accessToken?: string,
): Promise<ResponseType<T, O>> {
): Promise<T> {
if (!this.opts.idBaseUrl) {
throw new Error("No identity server base URL set");
}
@@ -125,17 +118,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
* When `paramOpts.doNotAttemptTokenRefresh` is true, token refresh will not be attempted
* when an expired token is encountered. Used to only attempt token refresh once.
*
* @returns Promise which resolves to
* ```
* {
* data: {Object},
* headers: {Object},
* code: {Number},
* }
* ```
* If `onlyData` is set, this will resolve to the `data` object only.
* @returns Rejects with an error if a problem occurred.
* This includes network problems and Matrix-specific error JSON.
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
*/
public authedRequest<T>(
method: Method,
@@ -143,7 +127,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
queryParams: QueryDict = {},
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
): Promise<T> {
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
}
@@ -155,7 +139,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
queryParams: QueryDict,
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
): Promise<T> {
// avoid mutating paramOpts so they can be used on retry
const opts = deepCopy(paramOpts);
// we have to manually copy the abortSignal over as it is not a plain object
@@ -221,18 +205,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param opts - additional options
*
* @returns Promise which resolves to
* ```
* {
* data: {Object},
* headers: {Object},
* code: {Number},
* }
* ```
* If `onlyData</code> is set, this will resolve to the <code>data`
* object only.
* @returns Rejects with an error if a problem
* occurred. This includes network problems and Matrix-specific error JSON.
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
*/
public request<T>(
method: Method,
@@ -240,7 +214,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
queryParams?: QueryDict,
body?: Body,
opts?: IRequestOpts,
): Promise<ResponseType<T, O>> {
): Promise<T> {
const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl);
return this.requestOtherUrl<T>(method, fullUri, body, opts);
}
@@ -254,30 +228,27 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param opts - additional options
*
* @returns Promise which resolves to data unless `onlyData` is specified as false,
* where the resolved value will be a fetch Response object.
* @returns Rejects with an error if a problem
* occurred. This includes network problems and Matrix-specific error JSON.
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
*/
public async requestOtherUrl<T>(
method: Method,
url: URL | string,
body?: Body,
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
): Promise<ResponseType<T, O>> {
opts: BaseRequestOpts = {},
): Promise<T> {
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
}
const urlForLogs = this.sanitizeUrlForLogs(url);
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
const headers = Object.assign({}, opts.headers || {});
const json = opts.json ?? true;
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
const jsonBody = json && body?.constructor?.name === Object.name;
if (json) {
if (jsonBody && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const jsonResponse = !opts.rawResponseBody && opts.json !== false;
if (jsonResponse) {
if (!headers["Accept"]) {
headers["Accept"] = "application/json";
}
@@ -293,9 +264,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
signals.push(opts.abortSignal);
}
// If the body is an object, encode it as JSON and set the `Content-Type` header,
// unless that has been explicitly inhibited by setting `opts.json: false`.
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
let data: BodyInit;
if (jsonBody) {
if (opts.json !== false && body?.constructor?.name === Object.name) {
data = JSON.stringify(body);
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
} else {
data = body as BodyInit;
}
@@ -337,10 +314,13 @@ 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 (opts.rawResponseBody) {
return (await res.blob()) as T;
} else if (jsonResponse) {
return await res.json();
} else {
return (await res.text()) as T;
}
return res as ResponseType<T, O>;
}
private sanitizeUrlForLogs(url: URL | string): string {
@@ -379,9 +359,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
? baseUrlWithFallback.slice(0, -1)
: baseUrlWithFallback;
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
if (queryParams) {
encodeParams(queryParams, url.searchParams);
// If there are any params, encode and append them to the URL.
if (this.opts.extraParams || queryParams) {
const mergedParams = { ...this.opts.extraParams, ...queryParams };
encodeParams(mergedParams, url.searchParams);
}
return url;
}
}
+2 -8
View File
@@ -48,9 +48,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
*
* @param opts - options object
*
* @returns Promise which resolves to response object, as
* determined by this.opts.onlyData, opts.rawResponse, and
* opts.onlyContentUri. Rejects with an error (usually a MatrixError).
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
*/
public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
const includeFilename = opts.includeFilename ?? true;
@@ -149,11 +147,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
prefix: MediaPrefix.V3,
headers,
abortSignal: abortController.signal,
})
.then((response) => {
return this.opts.onlyData ? <UploadResponse>response : response.json();
})
.then(uploadResolvers.resolve, uploadResolvers.reject);
}).then(uploadResolvers.resolve, uploadResolvers.reject);
}
// remove the upload from the list on completion
+48 -13
View File
@@ -16,6 +16,7 @@ limitations under the License.
import { type MatrixError } from "./errors.ts";
import { type Logger } from "../logger.ts";
import { type QueryDict } from "../utils.ts";
export type Body = Record<string, any> | BodyInit;
@@ -46,13 +47,15 @@ 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;
baseUrl: string;
idBaseUrl?: string;
prefix: string;
extraParams?: Record<string, string>;
extraParams?: QueryDict;
accessToken?: string;
/**
@@ -66,24 +69,17 @@ export interface IHttpOpts {
tokenRefreshFunction?: TokenRefreshFunction;
useAuthorizationHeader?: boolean; // defaults to true
/** For historical reasons, must be set to `true`. Will eventually be removed. */
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
*/
@@ -95,7 +91,46 @@ 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}).
*
* * Parse the response as JSON and return the parsed response.
*
* Setting this to `false` inhibits all three behaviors, and 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.
*
* * 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,
+75 -1
View File
@@ -1,6 +1,6 @@
/*
Copyright 2018 André Jaenisch
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019-2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -160,6 +160,9 @@ function getPrefixedLogger(prefix?: string): PrefixedLogger {
/**
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed.
*
* @deprecated avoid the use of this unless you are the constructor of `MatrixClient`: you should be using the logger
* associated with `MatrixClient`.
*/
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
@@ -203,3 +206,74 @@ export class LogSpan implements BaseLogger {
this.parent.error(this.name, ...msg);
}
}
/**
* A simplification of the `Debugger` type exposed by the `debug` library. We reimplement the bits we need here
* to avoid a dependency on `debug`.
*/
interface Debugger {
(formatter: any, ...args: any[]): void;
extend: (namespace: string, delimiter?: string) => Debugger;
}
/**
* A `Logger` instance, suitable for use in {@link ICreateClientOpts.logger}, which will write to the `debug` library.
*
* @example
* ```js
* import debug from "debug";
*
* const client = createClient({
* baseUrl: homeserverUrl,
* userId: userId,
* accessToken: "akjgkrgjs",
* deviceId: "xzcvb",
* logger: new DebugLogger(debug(`matrix-js-sdk:${userId}`)),
* });
* ```
*/
export class DebugLogger implements Logger {
public constructor(private debugInstance: Debugger) {}
public trace(...msg: any[]): void {
this.debugWithPrefix("[TRACE]", ...msg);
}
public debug(...msg: any[]): void {
this.debugWithPrefix("[DEBUG]", ...msg);
}
public info(...msg: any[]): void {
this.debugWithPrefix("[INFO]", ...msg);
}
public warn(...msg: any[]): void {
this.debugWithPrefix("[WARN]", ...msg);
}
public error(...msg: any[]): void {
this.debugWithPrefix("[ERROR]", ...msg);
}
public getChild(namespace: string): DebugLogger {
return new DebugLogger(this.debugInstance.extend(namespace));
}
private debugWithPrefix(prefix: string, ...msg: any[]): void {
let formatter: string;
// Convert the first argument to a string, so that we can safely add a prefix. This is much the same logic that
// `debug()` uses.
if (msg.length === 0) {
formatter = "";
} else if (msg[0] instanceof Error) {
const err = msg.shift();
formatter = err.stack || err.message;
} else if (typeof msg[0] == "string") {
formatter = msg.shift();
} else {
formatter = "%O";
}
this.debugInstance(prefix + " " + formatter, ...msg);
}
}
+1
View File
@@ -109,6 +109,7 @@ export { IdentityProviderBrand, SSOAction } from "./@types/auth.ts";
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth.ts";
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces.ts";
export { LocationAssetType } from "./@types/location.ts";
export { DebugLogger } from "./logger.ts";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
+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;
}
+27 -10
View File
@@ -5,7 +5,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts";
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type Statistics } from "./types.ts";
import { isMyMembership, type ParticipantId, type Statistics } from "./types.ts";
import { getParticipantId } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
@@ -40,10 +41,9 @@ export interface IEncryptionManager {
/**
* Retrieves the encryption keys currently managed by the encryption manager.
*
* @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps.
* @returns A map of participant IDs to their encryption keys.
*/
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>;
}
/**
@@ -82,6 +82,7 @@ export class EncryptionManager implements IEncryptionManager {
private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined;
private logger: Logger;
public constructor(
private userId: string,
private deviceId: string,
@@ -98,8 +99,16 @@ export class EncryptionManager implements IEncryptionManager {
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
}
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
return this.encryptionKeys;
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
const keysMap = new Map<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>();
for (const [userId, userKeys] of this.encryptionKeys) {
const keys = userKeys.map((entry, index) => ({
key: entry.key,
keyIndex: index,
}));
keysMap.set(userId as ParticipantId, keys);
}
return keysMap;
}
private joined = false;
@@ -280,10 +289,20 @@ export class EncryptionManager implements IEncryptionManager {
try {
this.statistics.counters.roomEventEncryptionKeysSent += 1;
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
const targets = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
this.encryptionKeys,
);
} catch (error) {
if (this.keysEventUpdateTimeout === undefined) {
@@ -408,8 +427,6 @@ export class EncryptionManager implements IEncryptionManager {
};
}
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
+2 -2
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type CallMembership } from "./CallMembership.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
export enum KeyTransportEvents {
ReceivedKeys = "received_keys",
@@ -45,7 +45,7 @@ export interface IKeyTransport {
* @param index
* @param members - The participants that should get they key
*/
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void>;
/** Subscribe to keys from this transport. */
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
+21 -6
View File
@@ -17,13 +17,21 @@ limitations under the License.
import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
import type { Status } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent {
StatusChanged = "StatusChanged",
/**
* Emitted when the membership manager has not heard back from the server for the duration
* of the delayed event and hence failed to restart the delayed event.
* This means that the user is probably not joined anymore and the leave event was distributed to other session members.
*/
ProbablyLeft = "ProbablyLeft",
}
export type MembershipManagerEventHandlerMap = {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
[MembershipManagerEvent.ProbablyLeft]: (probablyLeft: boolean) => void;
};
/**
@@ -33,7 +41,8 @@ export type MembershipManagerEventHandlerMap = {
*
* @internal
*/
export interface IMembershipManager {
export interface IMembershipManager
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap> {
/**
* If we are trying to join, or have successfully joined the session.
* It does not reflect if the room state is already configured to represent us being joined.
@@ -55,9 +64,19 @@ export interface IMembershipManager {
* Get the actual connection status of the manager.
*/
get status(): Status;
/**
* The current status while the manager is activated
* The Current own state event if the manger is connected.
* `undefined` if not connected.
*/
get ownMembership(): CallMembership | undefined;
/**
* If the membership manager has reason to believe that the hs sent a leave event
* and as a consequence the current user is perceived as left for other session participants.
*/
get probablyLeft(): boolean;
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
@@ -81,8 +100,4 @@ export interface IMembershipManager {
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
// TypedEventEmitter methods:
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
}
-414
View File
@@ -1,414 +0,0 @@
/*
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 { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import type { MatrixClient } from "../client.ts";
import { HTTPError, MatrixError } from "../http-api/errors.ts";
import { logger } from "../logger.ts";
import { EventTimeline } from "../models/event-timeline.ts";
import { type Room } from "../models/room.ts";
import { sleep } from "../utils.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { type EmptyObject } from "../@types/common.ts";
import { Status } from "./types.ts";
import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts";
/**
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
*
* Its responsibitiy is to manage the locals user membership:
* - send that sate event
* - send the delayed leave event
* - update the delayed leave event while connected
* - update the state event when it times out (for calls longer than membershipExpiryTimeout ~ 4h)
*
* It is possible to test this class on its own. The api surface (to use for tests) is
* defined in `MembershipManagerInterface`.
*
* It is recommended to only use this interface for testing to allow replacing this class.
*
* @internal
* @deprecated Use {@link MembershipManager} instead
*/
export class LegacyMembershipManager implements IMembershipManager {
private relativeExpiry: number | undefined;
private memberEventTimeout?: ReturnType<typeof setTimeout>;
/**
* This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
*/
private ownFociPreferred?: Focus[];
/**
* This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
*/
private ownFocusActive?: Focus;
private updateCallMembershipRunning = false;
private needCallMembershipUpdate = false;
/**
* If the server disallows the configured {@link delayedLeaveEventDelayMs},
* this stores a delay that the server does allow.
*/
private delayedLeaveEventDelayMsOverride?: number;
private disconnectDelayId: string | undefined;
private get networkErrorRetryMs(): number {
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
}
private get membershipEventExpiryMs(): number {
return (
this.joinConfig?.membershipEventExpiryMs ??
this.joinConfig?.membershipExpiryTimeout ??
DEFAULT_EXPIRE_DURATION
);
}
private get delayedLeaveEventDelayMs(): number {
return (
this.delayedLeaveEventDelayMsOverride ??
this.joinConfig?.delayedLeaveEventDelayMs ??
this.joinConfig?.membershipServerSideExpiryTimeout ??
8_000
);
}
private get delayedLeaveEventRestartMs(): number {
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
}
public constructor(
private joinConfig: MembershipConfig | undefined,
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
private client: Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
) {}
public off(
event: MembershipManagerEvent.StatusChanged,
listener: (oldStatus: Status, newStatus: Status) => void,
): this {
logger.error("off is not implemented on LegacyMembershipManager");
return this;
}
public on(
event: MembershipManagerEvent.StatusChanged,
listener: (oldStatus: Status, newStatus: Status) => void,
): this {
logger.error("on is not implemented on LegacyMembershipManager");
return this;
}
public isJoined(): boolean {
return this.relativeExpiry !== undefined;
}
public isActivated(): boolean {
return this.isJoined();
}
/**
* Unimplemented
* @returns Status.Unknown
*/
public get status(): Status {
return Status.Unknown;
}
public join(fociPreferred: Focus[], fociActive?: Focus): void {
this.ownFocusActive = fociActive;
this.ownFociPreferred = fociPreferred;
this.relativeExpiry = this.membershipEventExpiryMs;
// We don't wait for this, mostly because it may fail and schedule a retry, so this
// function returning doesn't really mean anything at all.
void this.triggerCallMembershipEventUpdate();
}
public async leave(timeout: number | undefined = undefined): Promise<boolean> {
this.relativeExpiry = undefined;
this.ownFocusActive = undefined;
if (this.memberEventTimeout) {
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
if (timeout) {
// The sleep promise returns the string 'timeout' and the membership update void
// A success implies that the membership update was quicker then the timeout.
const raceResult = await Promise.race([this.triggerCallMembershipEventUpdate(), sleep(timeout, "timeout")]);
return raceResult !== "timeout";
} else {
await this.triggerCallMembershipEventUpdate();
return true;
}
}
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const isMyMembership = (m: CallMembership): boolean =>
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
if (this.isJoined() && !memberships.some(isMyMembership)) {
logger.warn("Missing own membership: force re-join");
// TODO: Should this be awaited? And is there anything to tell the focus?
return this.triggerCallMembershipEventUpdate();
}
}
public getActiveFocus(): Focus | undefined {
if (this.ownFocusActive) {
// A livekit active focus
if (isLivekitFocusActive(this.ownFocusActive)) {
if (this.ownFocusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
} else {
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
}
} else {
// We do not understand the membership format (could be legacy). We default to oldestMembership
// Once there are other methods this is a hard error!
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
// TODO: Should this await on a shared promise?
if (this.updateCallMembershipRunning) {
this.needCallMembershipUpdate = true;
return;
}
this.updateCallMembershipRunning = true;
try {
// if anything triggers an update while the update is running, do another update afterwards
do {
this.needCallMembershipUpdate = false;
await this.updateCallMembershipEvent();
} while (this.needCallMembershipUpdate);
} finally {
this.updateCallMembershipRunning = false;
}
};
private makeNewMembership(deviceId: string): SessionMembershipData | EmptyObject {
// If we're joined, add our own
if (this.isJoined()) {
return this.makeMyMembership(deviceId);
}
return {};
}
/**
* Constructs our own membership
*/
private makeMyMembership(deviceId: string): SessionMembershipData {
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: deviceId,
expires: this.relativeExpiry,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.ownFociPreferred ?? [],
};
}
private async updateCallMembershipEvent(): Promise<void> {
if (this.memberEventTimeout) {
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId);
const localUserId = this.client.getUserId();
const localDeviceId = this.client.getDeviceId();
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
let newContent: EmptyObject | SessionMembershipData = {};
// TODO: add back expiary logic to non-legacy events
// previously we checked here if the event is timed out and scheduled a check if not.
// maybe there is a better way.
newContent = this.makeNewMembership(localDeviceId);
try {
if (this.isJoined()) {
const stateKey = this.makeMembershipStateKey(localUserId, localDeviceId);
const prepareDelayedDisconnection = async (): Promise<void> => {
try {
const res = await resendIfRateLimited(() =>
this.client._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.delayedLeaveEventDelayMs,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
stateKey,
),
);
this.disconnectDelayId = res.delay_id;
} catch (e) {
if (
e instanceof MatrixError &&
e.errcode === "M_UNKNOWN" &&
e.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
) {
const maxDelayAllowed = e.data["org.matrix.msc4140.max_delay"];
if (
typeof maxDelayAllowed === "number" &&
this.delayedLeaveEventDelayMs > maxDelayAllowed
) {
this.delayedLeaveEventDelayMsOverride = maxDelayAllowed;
return prepareDelayedDisconnection();
}
}
logger.error("Failed to prepare delayed disconnection event:", e);
}
};
await prepareDelayedDisconnection();
// Send join event _after_ preparing the delayed disconnection event
await resendIfRateLimited(() =>
this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, stateKey),
);
// If sending state cancels your own delayed state, prepare another delayed state
// TODO: Remove this once MSC4140 is stable & doesn't cancel own delayed state
if (this.disconnectDelayId !== undefined) {
try {
const knownDisconnectDelayId = this.disconnectDelayId;
await resendIfRateLimited(() =>
this.client._unstable_updateDelayedEvent(
knownDisconnectDelayId,
UpdateDelayedEventAction.Restart,
),
);
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
// If we get a M_NOT_FOUND we prepare a new delayed event.
// In other error cases we do not want to prepare anything since we do not have the guarantee, that the
// future is not still running.
logger.warn("Failed to update delayed disconnection event, prepare it again:", e);
this.disconnectDelayId = undefined;
await prepareDelayedDisconnection();
}
}
}
if (this.disconnectDelayId !== undefined) {
this.scheduleDelayDisconnection();
}
// TODO throw or log an error if this.disconnectDelayId === undefined
} else {
// Not joined
let sentDelayedDisconnect = false;
if (this.disconnectDelayId !== undefined) {
try {
const knownDisconnectDelayId = this.disconnectDelayId;
await resendIfRateLimited(() =>
this.client._unstable_updateDelayedEvent(
knownDisconnectDelayId,
UpdateDelayedEventAction.Send,
),
);
sentDelayedDisconnect = true;
} catch (e) {
logger.error("Failed to send our delayed disconnection event:", e);
}
this.disconnectDelayId = undefined;
}
if (!sentDelayedDisconnect) {
await resendIfRateLimited(() =>
this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
{},
this.makeMembershipStateKey(localUserId, localDeviceId),
),
);
}
}
logger.info("Sent updated call member event.");
} catch (e) {
const resendDelay = this.networkErrorRetryMs;
logger.warn(`Failed to send call member event (retrying in ${resendDelay}): ${e}`);
await sleep(resendDelay);
await this.triggerCallMembershipEventUpdate();
}
}
private scheduleDelayDisconnection(): void {
this.memberEventTimeout = setTimeout(() => void this.delayDisconnection(), this.delayedLeaveEventRestartMs);
}
private readonly delayDisconnection = async (): Promise<void> => {
try {
const knownDisconnectDelayId = this.disconnectDelayId!;
await resendIfRateLimited(() =>
this.client._unstable_updateDelayedEvent(knownDisconnectDelayId, UpdateDelayedEventAction.Restart),
);
this.scheduleDelayDisconnection();
} catch (e) {
logger.error("Failed to delay our disconnection event:", e);
}
};
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
return `_${stateKey}`;
}
}
}
async function resendIfRateLimited<T>(func: () => Promise<T>, numRetriesAllowed: number = 1): Promise<T> {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await func();
} catch (e) {
if (numRetriesAllowed > 0 && e instanceof HTTPError && e.isRateLimitError()) {
numRetriesAllowed--;
let resendDelay: number;
const defaultMs = 5000;
try {
resendDelay = e.getRetryAfterMs() ?? defaultMs;
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
} catch (e) {
logger.warn(
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
e,
);
resendDelay = defaultMs;
}
await sleep(resendDelay);
} else {
throw e;
}
}
}
}
+263 -81
View File
@@ -19,18 +19,29 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { EventTimeline } from "../models/event-timeline.ts";
import { type Room } from "../models/room.ts";
import { type MatrixClient } from "../client.ts";
import { EventType } from "../@types/event.ts";
import { EventType, RelationType } from "../@types/event.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { KnownMembership } from "../@types/membership.ts";
import { MembershipManager } from "./NewMembershipManager.ts";
import { MembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
import { logDurationSync } from "../utils.ts";
import { type Statistics } from "./types.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import {
type Statistics,
type RTCNotificationType,
type Status,
type IRTCNotificationContent,
type ICallNotifyContent,
} from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
import {
MembershipManagerEvent,
type MembershipManagerEventHandlerMap,
type IMembershipManager,
} from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
@@ -39,6 +50,9 @@ import {
import { TypedReEmitter } from "../ReEmitter.ts";
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
/**
* Events emitted by MatrixRTCSession
*/
export enum MatrixRTCSessionEvent {
// A member joined, left, or updated a property of their membership.
MembershipsChanged = "memberships_changed",
@@ -50,6 +64,8 @@ export enum MatrixRTCSessionEvent {
EncryptionKeyChanged = "encryption_key_changed",
/** The membership manager had to shut down caused by an unrecoverable error */
MembershipManagerError = "membership_manager_error",
/** The RTCSession did send a call notification caused by joining the call as the first member */
DidSendCallNotification = "did_send_call_notification",
}
export type MatrixRTCSessionEventHandlerMap = {
@@ -64,19 +80,41 @@ export type MatrixRTCSessionEventHandlerMap = {
participantId: string,
) => void;
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
[MatrixRTCSessionEvent.DidSendCallNotification]: (
notificationContentNew: { event_id: string } & IRTCNotificationContent,
notificationContentLegacy: { event_id: string } & ICallNotifyContent,
) => void;
};
export interface SessionConfig {
/**
* What kind of notification to send when starting the session.
* @default `undefined` (no notification)
*/
notificationType?: RTCNotificationType;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SessionDescription {
id: string;
application: string;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
// - we use membershipEvent if the option is related to the rtc member state event.
// - we use the technical term expiry if the option is related to the expiry field of the membership state event.
// - we use a `MS` postfix if the option is a duration to avoid using words like:
// - we use a `Ms` postfix if the option is a duration to avoid using words like:
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
export interface MembershipConfig {
/**
* Use the new Manager.
*
* Default: `false`.
* @deprecated does nothing anymore we always default to the new membership manager.
*/
useNewMembershipManager?: boolean;
@@ -87,8 +125,6 @@ export interface MembershipConfig {
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membershipEventExpiryMs?: number;
/** @deprecated renamed to `membershipEventExpiryMs`*/
membershipExpiryTimeout?: number;
/**
* The time in (in milliseconds) which the manager will prematurely send the updated state event before the membership `expires` time to make sure it
@@ -100,23 +136,17 @@ export interface MembershipConfig {
* This value does not have an effect on the value of `SessionMembershipData.expires`.
*/
membershipEventExpiryHeadroomMs?: number;
/** @deprecated renamed to `membershipEventExpiryHeadroomMs`*/
membershipExpiryTimeoutHeadroom?: number;
/**
* The timeout (in milliseconds) with which the deleayed leave event on the server is configured.
* After this time the server will set the event to the disconnected stat if it has not received a keep-alive from the client.
*/
delayedLeaveEventDelayMs?: number;
/** @deprecated renamed to `delayedLeaveEventDelayMs`*/
membershipServerSideExpiryTimeout?: number;
/**
* The interval (in milliseconds) in which the client will send membership keep-alives to the server.
*/
delayedLeaveEventRestartMs?: number;
/** @deprecated renamed to `delayedLeaveEventRestartMs`*/
membershipKeepAlivePeriod?: number;
/**
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a server rate limit has been hit.
@@ -133,13 +163,22 @@ export interface MembershipConfig {
* failed to send due to a network error. (send membership event, send delayed event, restart delayed event...)
*/
networkErrorRetryMs?: number;
/** @deprecated renamed to `networkErrorRetryMs`*/
callMemberEventRetryDelayMinimum?: number;
/**
* If true, use the new to-device transport for sending encryption keys.
*/
useExperimentalToDeviceTransport?: boolean;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
*
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayedLeaveEventRestartLocalTimeoutMs?: number;
}
export interface EncryptionConfig {
@@ -152,36 +191,55 @@ export interface EncryptionConfig {
/**
* The minimum time (in milliseconds) between each attempt to send encryption key(s).
* e.g. if this is set to 1000, then we will send at most one key event every second.
* @deprecated - Not used by the new encryption manager.
*/
updateEncryptionKeyThrottle?: number;
/**
* Sometimes it is necessary to rotate the encryption key after a membership update.
* For performance reasons we might not want to rotate the key immediately but allow future memberships to use the same key.
* If 5 people join in a row in less than 5 seconds, we don't want to rotate the key for each of them.
* If 5 people leave in a row in less than 5 seconds, we don't want to rotate the key for each of them.
* So we do share the key which was already used live for <5s to new joiners.
* This does result in a potential leak up to the configured time of call media.
* This has to be considered when choosing a value for this property.
*/
keyRotationGracePeriodMs?: number;
/**
* The delay (in milliseconds) after a member leaves before we create and publish a new key, because people
* tend to leave calls at the same time.
* @deprecated - Not used by the new encryption manager.
*/
makeKeyDelay?: number;
/**
* The delay (in milliseconds) between creating and sending a new key and starting to encrypt with it. This
* gives other a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
* The total time between a member leaving and the call switching to new keys is therefore:
* makeKeyDelay + useKeyDelay
* The delay (in milliseconds) between sending a new key and starting to encrypt with it. This
* gives others a chance to receive the new key to minimize the chance they get media they can't decrypt.
*
* The higher this value is, the better it is for existing members as they will have a smoother experience.
* But it impacts new joiners: They will always have to wait `useKeyDelay` before being able to decrypt the media
* (as it will be encrypted with the new key after the delay only), even if the key has already arrived before the delay.
*/
useKeyDelay?: number;
}
export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
/**
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
* This class doesn't deal with media at all, just membership & properties of a session.
*/
export class MatrixRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
> {
private membershipManager?: IMembershipManager;
private encryptionManager?: IEncryptionManager;
// The session Id of the call, this is the call_id of the call Member event.
private _callId: string | undefined;
private joinConfig?: SessionConfig;
private logger: Logger;
private pendingNotificationToSend: undefined | RTCNotificationType;
/**
* This timeout is responsible to track any expiration. We need to know when we have to start
* to ignore other call members. There is no callback for this. This timeout will always be configured to
@@ -202,6 +260,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
},
};
public get membershipStatus(): Status | undefined {
return this.membershipManager?.status;
}
public get probablyLeft(): boolean | undefined {
return this.membershipManager?.probablyLeft;
}
/**
* The callId (sessionId) of the call.
*
@@ -213,10 +279,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);
@@ -249,9 +332,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;
}
@@ -282,12 +366,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);
}
/**
@@ -332,10 +437,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);
@@ -363,8 +473,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
}
private reEmitter = new TypedReEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
>(this);
/**
@@ -387,22 +497,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
return;
} else {
// Create MembershipManager and pass the RTCSession logger (with room id info)
if (joinConfig?.useNewMembershipManager ?? false) {
this.membershipManager = new MembershipManager(
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.logger,
);
} else {
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
}
this.membershipManager = new MembershipManager(
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.sessionDescription,
this.logger,
);
this.reEmitter.reEmit(this.membershipManager!, [
MembershipManagerEvent.ProbablyLeft,
MembershipManagerEvent.StatusChanged,
]);
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
this.logger.info("Using experimental to-device transport for encryption keys");
this.logger.info("Using to-device with room fallback transport for encryption keys");
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
@@ -413,22 +525,45 @@ export class MatrixRTCSession extends TypedEventEmitter<
// Expose the changes so the ui can display the currently used transport.
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
this.encryptionManager = new RTCEncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
);
},
this.logger,
);
} else {
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
);
},
);
}
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
},
this.logger,
);
}
this.joinConfig = joinConfig;
this.pendingNotificationToSend = this.joinConfig?.notificationType;
// Join!
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
@@ -498,29 +633,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
* the keys.
*/
public reemitEncryptionKeys(): void {
this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => {
keys.forEach((key, index) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId);
this.encryptionManager?.getEncryptionKeys().forEach((keyRing, participantId) => {
keyRing.forEach((keyInfo) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyInfo.key, keyInfo.keyIndex, participantId);
});
});
}
/**
* A map of keys used to encrypt and decrypt (we are using a symmetric
* cipher) given participant's media. This also includes our own key
*
* @deprecated This will be made private in a future release.
*/
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
const keys =
this.encryptionManager?.getEncryptionKeys() ??
new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
// the returned array doesn't contain the timestamps
return Array.from(keys.entries())
.map(([participantId, keys]): [string, Uint8Array[]] => [participantId, keys.map((k) => k.key)])
.values();
}
/**
* Sets a timer for the soonest membership expiry
*/
@@ -545,6 +664,53 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
}
/**
* Sends a notification corresponding to the configured notify type.
*/
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
const sendLegacyNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: ICallNotifyContent;
}> => {
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": notificationType === "notification" ? "notify" : notificationType,
"call_id": this.callId!,
};
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.CallNotify, content);
return { response, content };
};
const sendNewNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: IRTCNotificationContent;
}> => {
const content: IRTCNotificationContent = {
"m.mentions": { user_ids: [], room: true },
"notification_type": notificationType,
"m.relates_to": {
event_id: parentEventId,
rel_type: RelationType.Reference,
},
"sender_ts": Date.now(),
"lifetime": 30_000, // 30 seconds
};
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content);
return { response, content };
};
void Promise.all([sendLegacyNotificationEvent(), sendNewNotificationEvent()])
.then(([legacy, newNotification]) => {
// Join event_id and origin event content
const legacyResult = { ...legacy.response, ...legacy.content };
const newResult = { ...newNotification.response, ...newNotification.content };
this.emit(MatrixRTCSessionEvent.DidSendCallNotification, newResult, legacyResult);
})
.catch(([errorLegacy, errorNew]) =>
this.logger.error("Failed to send call notification", errorLegacy, errorNew),
);
}
/**
* Call this when the Matrix room members have changed.
*/
@@ -568,21 +734,37 @@ 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 ||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
if (changed) {
this.logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
this.logger.info(
`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`,
);
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
});
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
// The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`.
const ownMembership = this.membershipManager?.ownMembership;
if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) {
// If we're the first member in the call, we're responsible for
// sending the notification event
if (ownMembership.eventId && this.joinConfig?.notificationType) {
this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType);
} else {
this.logger.warn("Own membership eventId is undefined, cannot send call notification");
}
}
// If anyone else joins the session it is no longer our responsibility to send the notification.
// (If we were the joiner we already did sent the notification in the block above.)
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
}
// This also needs to be done if `changed` = false
// A member might have updated their fingerprint (created_ts)
+17 -6
View File
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger as rootLogger, type Logger } from "../logger.ts";
import { type Logger } from "../logger.ts";
import { type MatrixClient, ClientEvent } from "../client.ts";
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> {
@@ -48,8 +51,13 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// longer the correct session object for the room.
private roomSessions = new Map<string, MatrixRTCSession>();
private logger: Logger;
public constructor(private client: MatrixClient) {
private readonly logger: Logger;
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]");
}
@@ -58,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);
}
@@ -92,7 +100,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
*/
public getRoomSession(room: Room): MatrixRTCSession {
if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set(room.roomId, MatrixRTCSession.roomSessionForRoom(this.client, room));
this.roomSessions.set(
room.roomId,
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
);
}
return this.roomSessions.get(room.roomId)!;
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { AbortError } from "p-retry";
import { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
@@ -21,13 +22,12 @@ import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts";
import { defer, type IDeferred } from "../utils.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts";
import { isMyMembership, Status } from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.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 {
MembershipManagerEvent,
@@ -86,19 +86,24 @@ export enum MembershipActionType {
// -> MembershipActionType.SendJoinEvent if successful
// -> DelayedLeaveActionType.SendDelayedEvent on error, retry sending the first delayed event.
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
SendJoinEvent = "SendJoinEvent",
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
RestartDelayedEvent = "RestartDelayedEvent",
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
// -> DelayedLeaveActionType.SendDelayedEvent on missing delay id and there is no state event
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
UpdateExpiry = "UpdateExpiry",
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
// -> MembershipActionType.SendLeaveEvent on failure (not found) we need to send the leave manually and cannot use the scheduled delayed event
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
SendLeaveEvent = "SendLeaveEvent",
// -> MembershipActionType.SendLeaveEvent
}
@@ -124,6 +129,12 @@ export interface MembershipManagerState {
rateLimitRetries: Map<MembershipActionType, number>;
/** Retry counter for other errors */
networkErrorRetries: Map<MembershipActionType, number>;
/** The time at which we expect the server to send the delayed leave event. */
expectedServerDelayLeaveTs?: number;
/** This is used to track if the client expects the scheduled delayed leave event to have
* been sent because restarting failed during the available time.
* Once we resend the delayed event or successfully restarted it will get unset. */
probablyLeft: boolean;
}
/**
@@ -209,19 +220,26 @@ export class MembershipManager
// So we do not check scheduler.actions/scheduler.insertions
if (!this.leavePromiseResolvers) {
// reset scheduled actions so we will not do any new actions.
this.leavePromiseResolvers = defer<boolean>();
this.leavePromiseResolvers = Promise.withResolvers<boolean>();
this.activated = false;
this.scheduler.initiateLeave();
if (timeout) setTimeout(() => this.leavePromiseResolvers?.resolve(false), timeout);
}
return this.leavePromiseResolvers.promise;
}
private leavePromiseResolvers?: IDeferred<boolean>;
private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) {
if (!userId || !deviceId) {
this.logger.error("MembershipManager.onRTCSessionMemberUpdate called without user or device id");
return Promise.resolve();
}
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
if (this.isActivated() && !this._ownMembership) {
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
// take care of our missing membership.
const sendingMembershipActions = [
@@ -229,14 +247,15 @@ export class MembershipManager
MembershipActionType.SendJoinEvent,
];
this.logger.warn("Missing own membership: force re-join");
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
this.state.hasMemberStateEvent = false;
if (this.scheduler.actions.some((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
this.logger.error(
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
"tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
this.scheduler.actions,
);
} else {
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
this.state.hasMemberStateEvent = false;
this.scheduler.initiateJoin();
}
}
@@ -281,10 +300,11 @@ export class MembershipManager
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
public readonly sessionDescription: SessionDescription,
parentLogger?: Logger,
) {
super();
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`);
this.logger = (parentLogger ?? rootLogger).getChild(`[MembershipManager]`);
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
if (userId === null) throw Error("Missing userId in client");
if (deviceId === null) throw Error("Missing deviceId in client");
@@ -309,6 +329,11 @@ export class MembershipManager
}, this.logger);
}
private _ownMembership?: CallMembership;
public get ownMembership(): CallMembership | undefined {
return this._ownMembership;
}
// scheduler
private oldStatus?: Status;
private scheduler: ActionScheduler;
@@ -324,6 +349,7 @@ export class MembershipManager
rateLimitRetries: new Map(),
networkErrorRetries: new Map(),
expireUpdateIterations: 1,
probablyLeft: false,
};
}
// Membership Event static parameters:
@@ -336,35 +362,22 @@ export class MembershipManager
private delayedLeaveEventDelayMsOverride?: number;
private get networkErrorRetryMs(): number {
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
return this.joinConfig?.networkErrorRetryMs ?? 3_000;
}
private get membershipEventExpiryMs(): number {
return (
this.joinConfig?.membershipEventExpiryMs ??
this.joinConfig?.membershipExpiryTimeout ??
DEFAULT_EXPIRE_DURATION
);
return this.joinConfig?.membershipEventExpiryMs ?? DEFAULT_EXPIRE_DURATION;
}
private get membershipEventExpiryHeadroomMs(): number {
return (
this.joinConfig?.membershipEventExpiryHeadroomMs ??
this.joinConfig?.membershipExpiryTimeoutHeadroom ??
5_000
);
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
}
private computeNextExpiryActionTs(iteration: number): number {
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
}
private get delayedLeaveEventDelayMs(): number {
return (
this.delayedLeaveEventDelayMsOverride ??
this.joinConfig?.delayedLeaveEventDelayMs ??
this.joinConfig?.membershipServerSideExpiryTimeout ??
8_000
);
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
}
private get delayedLeaveEventRestartMs(): number {
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
return this.joinConfig?.delayedLeaveEventRestartMs ?? 5_000;
}
private get maximumRateLimitRetryCount(): number {
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
@@ -373,6 +386,9 @@ export class MembershipManager
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
}
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
}
// LOOP HANDLER:
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
switch (type) {
@@ -382,13 +398,13 @@ export class MembershipManager
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
} else {
// This can happen if someone else (or another client) removes our own membership event.
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendDelayedEvent`.
// We might still have our delayed event from the previous participation and dependent on the server this might not
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
//
// In this block we will try to cancel this delayed event before setting up a new one.
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
return this.cancelKnownDelayIdBeforeSendDelayedEvent(this.state.delayId);
}
}
case MembershipActionType.RestartDelayedEvent: {
@@ -444,6 +460,8 @@ export class MembershipManager
this.stateKey,
)
.then((response) => {
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
this.setAndEmitProbablyLeft(false);
// On success we reset retries and set delayId.
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
this.state.delayId = response.delay_id;
@@ -488,7 +506,7 @@ export class MembershipManager
});
}
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Remove all running updates and restarts
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
@@ -523,17 +541,58 @@ export class MembershipManager
});
}
private setAndEmitProbablyLeft(probablyLeft: boolean): void {
if (this.state.probablyLeft === probablyLeft) {
return;
}
this.state.probablyLeft = probablyLeft;
this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft);
}
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart)
// Compute the duration until we expect the server to send the delayed leave event.
const durationUntilServerDelayedLeave = this.state.expectedServerDelayLeaveTs
? this.state.expectedServerDelayLeaveTs - Date.now()
: undefined;
const abortPromise = new Promise((_, reject) => {
setTimeout(
() => {
reject(new AbortError("Restart delayed event timed out before the HS responded"));
},
// We abort immediately at the time where we expect the server to send the delayed leave event.
// At this point we want the catch block to run and set the `probablyLeft` state.
//
// While we are already in probablyLeft state, we use the unaltered delayedLeaveEventRestartLocalTimeoutMs.
durationUntilServerDelayedLeave !== undefined && !this.state.probablyLeft
? Math.min(this.delayedLeaveEventRestartLocalTimeoutMs, durationUntilServerDelayedLeave)
: this.delayedLeaveEventRestartLocalTimeoutMs,
);
});
// The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded
// to the widget driver this information would get lost. That is why we mimic the AbortError using the race.
return await Promise.race([
this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart),
abortPromise,
])
.then(() => {
// Whenever we successfully restart the delayed event we update the `state.expectedServerDelayLeaveTs`
// which stores the predicted timestamp at which the server will send the delayed leave event if there wont be any further
// successful restart requests.
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
this.setAndEmitProbablyLeft(false);
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.delayedLeaveEventRestartMs,
);
})
.catch((e) => {
if (this.state.expectedServerDelayLeaveTs && this.state.expectedServerDelayLeaveTs <= Date.now()) {
// Once we reach this point it's likely that the server is sending the delayed leave event so we emit `probablyLeft = true`.
// It will emit `probablyLeft = false` once we notice about our leave through sync and successfully setup a new state event.
this.setAndEmitProbablyLeft(true);
}
const repeatActionType = MembershipActionType.RestartDelayedEvent;
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
@@ -588,13 +647,27 @@ export class MembershipManager
this.stateKey,
)
.then(() => {
this.setAndEmitProbablyLeft(false);
this.state.startTime = Date.now();
// The next update should already use twice the membershipEventExpiryTimeout
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),
@@ -656,7 +729,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 {
@@ -669,9 +742,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" },
@@ -774,14 +848,19 @@ export class MembershipManager
private actionUpdateFromNetworkErrorRetry(error: unknown, type: MembershipActionType): ActionUpdate | undefined {
// "Is a network error"-boundary
const retries = this.state.networkErrorRetries.get(type) ?? 0;
// Strings for error logging
const retryDurationString = this.networkErrorRetryMs / 1000 + "s";
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
// Variables for scheduling the new event
let retryDuration = this.networkErrorRetryMs;
if (error instanceof Error && error.name === "AbortError") {
// We do not wait for the timeout on local timeouts.
retryDuration = 0;
this.logger.warn(
"Network local timeout error while sending event, retrying in " +
retryDurationString +
" " +
retryCounterString,
"Network local timeout error while sending event, immediate retry (" + retryCounterString + ")",
error,
);
} else if (error instanceof Error && error.message.includes("updating delayed event")) {
@@ -824,7 +903,7 @@ export class MembershipManager
// retry boundary
if (retries < this.maximumNetworkErrorRetryCount) {
this.state.networkErrorRetries.set(type, retries + 1);
return createInsertActionUpdate(type, this.networkErrorRetryMs);
return createInsertActionUpdate(type, retryDuration);
}
// Failure
@@ -892,6 +971,10 @@ export class MembershipManager
this.logger.error("MembershipManager has an unknown state. Actions: ", actions);
return Status.Unknown;
}
public get probablyLeft(): boolean {
return this.state.probablyLeft;
}
}
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
@@ -1,7 +1,7 @@
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type EmptyObject } from "../matrix.ts";
import { sleep } from "../utils.ts";
import { MembershipActionType } from "./NewMembershipManager.ts";
import { MembershipActionType } from "./MembershipManager.ts";
/** @internal */
export interface Action {
+394
View File
@@ -0,0 +1,394 @@
/*
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 { type IEncryptionManager } from "./EncryptionManager.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { type CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
import { sleep } from "../utils.ts";
import type {
InboundEncryptionSession,
OutboundEncryptionSession,
ParticipantDeviceInfo,
ParticipantId,
Statistics,
} from "./types.ts";
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
RoomAndToDeviceTransport,
} from "./RoomAndToDeviceKeyTransport.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
*
* It is responsible for distributing the keys to the other participants and rotating the keys if needed.
*
* This manager when used with to-device transport will share the existing key only to new joiners, and rotate
* if there is a leaver.
*
* XXX In the future we want to distribute a ratcheted key not the current one for new joiners.
*/
export class RTCEncryptionManager implements IEncryptionManager {
// This is a stop-gap solution for now. The preferred way to handle this case would be instead
// to create a NoOpEncryptionManager that does nothing and use it for the session.
// This will be done when removing the legacy EncryptionManager.
private manageMediaKeys = false;
/**
* Store the key rings for each participant.
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
*/
private participantKeyRings = new Map<ParticipantId, Array<{ key: Uint8Array; keyIndex: number }>>();
// The current per-sender media key for this device
private outboundSession: OutboundEncryptionSession | null = null;
/**
* Ensures that there is only one distribute operation at a time for that call.
*/
private currentKeyDistributionPromise: Promise<void> | null = null;
/**
* The time to wait before using the outbound session after it has been distributed.
* This is to ensure that the key is delivered to all participants before it is used.
* When creating the first key, this is set to 0 so that the key can be used immediately.
*/
private useKeyDelay = 5000;
/**
* We want to avoid rolling out a new outbound key when the previous one was created less than `keyRotationGracePeriodMs` milliseconds ago.
* This is to avoid expensive key rotations when users quickly join the call in a row.
*
* This must be higher than `useKeyDelay` to have an effect.
* If it is lower, the current key will always be older than the grace period.
* @private
*/
private keyRotationGracePeriodMs = 10_000;
/**
* If a new key distribution is being requested while one is going on, we will set this flag to true.
* This will ensure that a new round is started after the current one.
* @private
*/
private needToEnsureKeyAgain = false;
/**
* There is a possibility that keys arrive in the wrong order.
* For example, after a quick join/leave/join, there will be 2 keys of index 0 distributed, and
* if they are received in the wrong order, the stream won't be decryptable.
* For that reason we keep a small buffer of keys for a limited time to disambiguate.
* @private
*/
private keyBuffer = new OutdatedKeyFilter();
private logger: Logger | undefined = undefined;
public constructor(
private userId: string,
private deviceId: string,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array,
encryptionKeyIndex: number,
participantId: ParticipantId,
) => void,
parentLogger?: Logger,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
}
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
return new Map(this.participantKeyRings);
}
private addKeyToParticipant(key: Uint8Array, keyIndex: number, participantId: ParticipantId): void {
if (!this.participantKeyRings.has(participantId)) {
this.participantKeyRings.set(participantId, []);
}
this.participantKeyRings.get(participantId)!.push({ key, keyIndex });
this.onEncryptionKeysChanged(key, keyIndex, participantId);
}
public join(joinConfig: EncryptionConfig | undefined): void {
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
this.logger?.info(`Joining room`);
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
// Deprecate RoomKeyTransport: this can get removed.
if (this.transport instanceof RoomAndToDeviceTransport) {
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
}
this.transport.start();
}
public leave(): void {
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
this.participantKeyRings.clear();
}
// Temporary for backwards compatibility
// TODO: Remove this in the future
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
this.logger?.info("Transport change detected, restarting key distribution");
if (this.currentKeyDistributionPromise) {
this.currentKeyDistributionPromise
.then(() => {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
})
.catch((e) => {
this.logger?.error("Failed to restart key distribution", e);
});
} else {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
}
};
/**
* Will ensure that a new key is distributed and used to encrypt our media.
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
* If this function is called repeatedly while a distribution is in progress,
* 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
this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => {
this.logger?.debug(`Rollout completed`);
this.currentKeyDistributionPromise = null;
if (this.needToEnsureKeyAgain) {
this.logger?.debug(`New Rollout needed`);
this.needToEnsureKeyAgain = false;
// rollout a new one
this.ensureKeyDistribution();
}
});
} else {
// There is a rollout in progress, but a key rotation is requested (could be caused by a membership change)
// Remember that a new rotation is needed after the current one.
this.logger?.debug(`Rollout in progress, a new rollout will be started after the current one`);
this.needToEnsureKeyAgain = true;
}
}
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.
const participantId = getParticipantId(userId, deviceId);
const keyBin = decodeBase64(keyBase64Encoded);
const candidateInboundSession: InboundEncryptionSession = {
key: keyBin,
participantId,
keyIndex: index,
creationTS: timestamp,
};
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
if (!outdated) {
this.addKeyToParticipant(
candidateInboundSession.key,
candidateInboundSession.keyIndex,
candidateInboundSession.participantId,
);
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
} else {
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
}
};
/**
* Called when the membership of the call changes.
* This encryption manager is very basic, it will rotate the key everytime this is called.
* @param oldMemberships - This parameter is not used here, but it is kept for compatibility with the interface.
*/
public onMembershipsUpdate(oldMemberships: CallMembership[] = []): void {
this.logger?.trace(`onMembershipsUpdate`);
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
// If there is an ongoing distribution, it will be completed before a new one is started.
this.ensureKeyDistribution();
}
private async rolloutOutboundKey(): Promise<void> {
const isFirstKey = this.outboundSession == null;
if (isFirstKey) {
// create the first key
this.outboundSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: 0,
};
this.addKeyToParticipant(
this.outboundSession.key,
this.outboundSession.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
// get current memberships
const toShareWith: ParticipantDeviceInfo[] = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
// Some users might have rotate their membership event (formally called fingerprint) meaning they might have
// clear their key. Reset the `alreadySharedWith` flag for them.
alreadySharedWith = alreadySharedWith.filter(
(x) =>
// If there was a member with same userId and deviceId but different membershipTs, we need to clear it
!toShareWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs != o.membershipTs,
),
);
const anyLeft = alreadySharedWith.filter(
(x) =>
!toShareWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
),
);
const anyJoined = toShareWith.filter(
(x) =>
!alreadySharedWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
),
);
let toDistributeTo: ParticipantDeviceInfo[] = [];
let outboundKey: OutboundEncryptionSession;
let hasKeyChanged = false;
if (anyLeft.length > 0) {
// We need to rotate the key
const newOutboundKey = this.createNewOutboundSession();
hasKeyChanged = true;
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
} else if (anyJoined.length > 0) {
const now = Date.now();
const keyAge = now - this.outboundSession!.creationTS;
// If the current key is recently created (less than `keyRotationGracePeriodMs`), we can keep it and just distribute it to the new joiners.
if (keyAge < this.keyRotationGracePeriodMs) {
// keep the same key
// XXX In the future we want to distribute a ratcheted key, not the current one
this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
toDistributeTo = anyJoined;
outboundKey = this.outboundSession!;
} else {
// We need to rotate the key
this.logger?.debug(`New joiners detected, rotating the key`);
const newOutboundKey = this.createNewOutboundSession();
hasKeyChanged = true;
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
}
} else {
// no changes
return;
}
try {
this.logger?.trace(`Sending key...`);
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
this.statistics.counters.roomEventEncryptionKeysSent += 1;
outboundKey.sharedWith.push(...toDistributeTo);
this.logger?.trace(
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
);
if (hasKeyChanged) {
// Delay a bit before using this key
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
await sleep(this.useKeyDelay);
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
this.addKeyToParticipant(
outboundKey.key,
outboundKey.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
} catch (err) {
this.logger?.error(`Failed to rollout key`, err);
}
}
private createNewOutboundSession(): OutboundEncryptionSession {
const newOutboundKey: OutboundEncryptionSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: this.nextKeyIndex(),
};
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
// Set this new key as the current one
this.outboundSession = newOutboundKey;
return newOutboundKey;
}
private nextKeyIndex(): number {
if (this.outboundSession) {
return (this.outboundSession!.keyId + 1) % 256;
}
return 0;
}
private generateRandomKey(): Uint8Array {
const key = new Uint8Array(16);
globalThis.crypto.getRandomValues(key);
return key;
}
}
+2 -2
View File
@@ -16,10 +16,10 @@ limitations under the License.
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type CallMembership } from "./CallMembership.ts";
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
export interface EnabledTransports {
@@ -106,7 +106,7 @@ export class RoomAndToDeviceTransport
this.toDeviceTransport.stop();
}
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
this.logger.debug(
`Sending key with index ${index} to call members (count=${members.length}) via:` +
(this._enabled.room ? "room transport" : "") +

Some files were not shown because too many files have changed in this diff Show More