Compare commits

..

109 Commits

Author SHA1 Message Date
RiotRobot aa7a709e3f v37.12.0 2025-07-29 12:58:45 +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
RiotRobot 40bc833bb7 v37.8.0 2025-06-10 12:00:13 +00:00
ElementRobot a46ad75440 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 (#4868) (#4869)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 12:52:35 +01: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
RiotRobot eb9867a5ba v37.7.0 2025-06-03 14:50:05 +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
RiotRobot 76eabb2efa v37.7.0-rc.0 2025-05-28 13:02:09 +00:00
Robin bf6dc16ad3 Allow the embedded client to work without update_state support (#4849)
* Allow the embedded client to work without UpdateState version

* Test that RoomWidgetClient can receive state without update_state

* add sliding sync test

* sliding sync receive test

* review

* add doc comment

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-05-23 15:09:52 +00:00
Timo 9398271695 Check for unknown variant on to-device sending and fall back to room event encryption. (#4847)
* Check for `unknown variant` on to-device sending and fallback to room event encryption.

* fix tests

* fix error js-sdk api type

* Change logger from debug to warn for unsupported to-device transport and improve error message comments

* also add case for not supported
This will be send by the driver in case we sent an encrypted to-device but do not have support of that.

---------

Co-authored-by: Robin <robin@robin.town>
2025-05-22 13:43:47 +00:00
Andy Balaam ef7a818f70 Bump matrix-sdk-crypto-wasm to 14.2.0 (#4848)
For better logging when a backed up key fails to deserialise.
2025-05-22 12:03:55 +00:00
Robin b8903ddf3e Reapply "Distinguish room state and timeline events in embedded clients" (#4790)
This reverts commit fd9a44e701.

We are ready to reintroduce support for the `update_state` widget action (https://github.com/matrix-org/matrix-spec-proposals/pull/4237) now that matrix-rust-sdk is about to gain support for it as well.
2025-05-20 16:03:24 +00:00
RiotRobot c35c7d1a3b Merge branch 'master' into develop 2025-05-20 13:27:51 +00:00
RiotRobot 286e00c500 v37.6.0 2025-05-20 13:27:14 +00:00
renovate[bot] 26922a61f3 Update dependency debug to v4.4.1 (#4846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-14 14:52:36 +00:00
Timo 457a300c95 MatrixRTC: Rename MembershipConfig parameters (#4714)
* Remove redundant sendDelayedEventAction
We do already have the state `hasMemberEvent` that allows to distinguish the two cases. No need to create two dedicated actions.

* fix missing return

* Make membership manager an event emitter to inform about status updates.
 - deprecate isJoined (replaced by isActivated)
 - move Interface types to types.ts

* add tests for status updates.

* lint

* test "reschedules delayed leave event" in case the delayed event gets canceled

* review

* fix types

* prettier

* fix legacy membership manager

* remove deprecated jitter.

* use non deprecated config fields (keep deprecated fields as fallback)

* update tests to test non deprecated names

* make local NewMembershipManager variable names consistent with config

* make LegacyMembershipManger local variables consistent with config

* comments and rename `networkErrorLocalRetryMs` -> `networkErrorRetryMs`

* review
2025-05-13 20:15:41 +00:00
Michael Telatynski be04f003ce Remove @types/uuid - uuid has its own types now (#4845)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-13 18:07:22 +00:00
dependabot[bot] 67b445d40d Bump base-x from 5.0.0 to 5.0.1 (#4844)
Bumps [base-x](https://github.com/cryptocoinjs/base-x) from 5.0.0 to 5.0.1.
- [Commits](https://github.com/cryptocoinjs/base-x/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: base-x
  dependency-version: 5.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-13 12:51:33 +00:00
renovate[bot] 27f28d5558 Pin dependencies (#4843)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 12:13:04 +00:00
renovate[bot] 52bac9648b Update dependency typedoc-plugin-mdn-links to v5.0.2 (#4842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 12:11:22 +00:00
renovate[bot] 7e0e5a3243 Update all non-major dependencies (#4836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 11:49:55 +00:00
renovate[bot] 337d1791cf Update babel monorepo (#4837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 11:00:33 +00:00
renovate[bot] 622998e949 Update dependency @types/node to v18.19.100 (#4838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 11:00:13 +00:00
renovate[bot] 673806ecaa Update dependency typedoc to v0.28.4 (#4839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 10:59:41 +00:00
renovate[bot] b2232289a6 Update typescript-eslint monorepo to v8.32.0 (#4840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 10:59:26 +00:00
renovate[bot] 344c8fad9d Update dependency lint-staged to v16 (#4841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 10:59:18 +00:00
RiotRobot cccaaf6e56 v37.6.0-rc.0 2025-05-13 10:54:01 +00:00
Michael Telatynski 4dbca983b4 Fix autodiscovery handling of 2xx (non-200) codes (#4833)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-12 18:51:30 +00:00
Michael Telatynski 54e8f3c9d0 Simplify global types (#4831)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-12 09:02:49 +00:00
Michael Telatynski 1fcc375dd5 Deprecate utils function defer in favour of Promise.withResolvers (#4829)
* Switch from defer to Promise.withResolvers

As supported by the outgoing LTS version (v22) which has 99% support of ES2024

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

* delint

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

* Deprecate defer instead of killing it

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

* Knip

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

* Iterate based on review

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

* Iterate based on review

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

* Iterate based on review

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

* Improve coverage

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-09 10:16:35 +00:00
Michael Telatynski d24c5d8b2b Update to Node 22 LTS (#4832)
Switch typescript lib to es2024 and make necessary type changes

Fixes https://github.com/matrix-org/matrix-js-sdk/issues/4796

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-08 13:38:18 +00:00
Richard van der Hoff bb9280ad6b Write a log line when cancelling verification (#4828)
... in an attempt to figure out what might have caused a spurious cancellation
today
2025-05-08 09:55:22 +00:00
RiotRobot 00bd7f0f02 Merge branch 'master' into develop 2025-05-06 13:48:19 +00:00
RiotRobot a29b8736f3 v37.5.0 2025-05-06 13:47:50 +00:00
ElementRobot 1c8a1cd5a1 Fix token refresh behaviour for non-expired tokens (#4825) (#4827)
The condition was inverted here, but the tests were passing because
they didn't add enough expiry time for the token expiry to be over
the threshold.

Fix the condition and tests, add another test and generally add a
bunch of comments so hopefully this is less confusing for the next
person.

Fixes https://github.com/element-hq/element-web/issues/29858

(cherry picked from commit fea619d34c)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-05-06 14:20:24 +01:00
David Baker fea619d34c Fix token refresh behaviour for non-expired tokens (#4825)
The condition was inverted here, but the tests were passing because
they didn't add enough expiry time for the token expiry to be over
the threshold.

Fix the condition and tests, add another test and generally add a
bunch of comments so hopefully this is less confusing for the next
person.

Fixes https://github.com/element-hq/element-web/issues/29858
2025-05-06 09:39:13 +00:00
renovate[bot] f322f32a07 Update eslint (#4786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 11:36:13 +00:00
renovate[bot] bc3abd4394 Update dependency typedoc-plugin-coverage to v4 (#4824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:48:34 +00:00
renovate[bot] 6721aca293 Update typescript-eslint monorepo to v8.31.0 (#4823)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:48 +00:00
renovate[bot] 80b51c0e30 Update dependency typedoc to v0.28.3 (#4822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:35 +00:00
renovate[bot] 91aa1dc092 Update dependency @types/node to v18.19.87 (#4821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:22 +00:00
renovate[bot] 78972e81d1 Update all non-major dependencies (#4820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:09 +00:00
121 changed files with 5632 additions and 3167 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@610b1987249a69a79de9565777e112fb38f22436 # 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@610b1987249a69a79de9565777e112fb38f22436 # 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@610b1987249a69a79de9565777e112fb38f22436 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -23,7 +23,7 @@ jobs:
path: docs
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
with:
path: docs
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
+4 -4
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: |
@@ -35,7 +35,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -60,7 +60,7 @@ jobs:
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
github.rest.issues.addLabels({
@@ -79,7 +79,7 @@ jobs:
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
github.rest.issues.createComment({
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check for X-Release-Blocker label on any open issues or PRs
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
REPO: ${{ inputs.repository }}
with:
@@ -16,12 +16,12 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
ref: staging
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: package.json
cache: "yarn"
@@ -37,7 +37,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -48,7 +48,7 @@ jobs:
- name: Ingest upstream changes
if: inputs.include-changes
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
+3 -3
View File
@@ -18,14 +18,14 @@ jobs:
merge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
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@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
+6 -6
View File
@@ -73,7 +73,7 @@ jobs:
draft: true
latest: true
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
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@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const { VERSION } = process.env;
@@ -125,7 +125,7 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -218,7 +218,7 @@ jobs:
- name: Validate release has expected assets
if: inputs.expected-asset-count
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
@@ -246,7 +246,7 @@ jobs:
git push origin master
- name: Publish release
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }}
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
ref: staging
- name: 🔧 Yarn cache
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
registry-url: "https://registry.npmjs.org"
+6 -6
View File
@@ -44,13 +44,13 @@ jobs:
repo:
- element-hq/element-web
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: "lts/*"
@@ -73,10 +73,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 🔧 Yarn cache
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -88,7 +88,7 @@ jobs:
run: yarn gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: _docs
@@ -106,4 +106,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
+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
@@ -43,7 +43,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: 📥 Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
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@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -75,7 +75,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v4.0
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
@@ -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 }}
+16 -16
View File
@@ -14,9 +14,9 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -31,9 +31,9 @@ jobs:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -48,9 +48,9 @@ jobs:
name: "Node.js example"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -61,7 +61,7 @@ jobs:
- name: Build Types
run: "yarn build:types"
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "npm"
node-version-file: "examples/node/package.json"
@@ -83,9 +83,9 @@ jobs:
name: "Workflow Lint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -100,9 +100,9 @@ jobs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -114,7 +114,7 @@ jobs:
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: docs
path: _docs
@@ -125,9 +125,9 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: package.json
@@ -143,11 +143,11 @@ jobs:
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
repository: element-hq/element-web
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: "lts/*"
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Setup Node
id: setupNode
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: ${{ matrix.node }}
@@ -57,7 +57,7 @@ jobs:
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
@@ -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 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
operations-per-run: 250
days-before-issue-stale: -1
+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
+95
View File
@@ -1,3 +1,98 @@
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
* [Backport staging] Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 ([#4869](https://github.com/matrix-org/matrix-js-sdk/pull/4869)). Contributed by @RiotRobot.
Changes in [37.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.7.0) (2025-06-03)
==================================================================================================
## 🦖 Deprecations
* MatrixRTC: Rename `MembershipConfig` parameters ([#4714](https://github.com/matrix-org/matrix-js-sdk/pull/4714)). Contributed by @toger5.
## ✨ Features
* Allow the embedded client to work without update\_state support ([#4849](https://github.com/matrix-org/matrix-js-sdk/pull/4849)). Contributed by @robintown.
* Check for `unknown variant` on to-device sending and fall back to room event encryption. ([#4847](https://github.com/matrix-org/matrix-js-sdk/pull/4847)). Contributed by @toger5.
* Reapply "Distinguish room state and timeline events in embedded clients" ([#4790](https://github.com/matrix-org/matrix-js-sdk/pull/4790)). Contributed by @robintown.
Changes in [37.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.6.0) (2025-05-20)
==================================================================================================
## 🦖 Deprecations
* Deprecate utils function `defer` in favour of `Promise.withResolvers` ([#4829](https://github.com/matrix-org/matrix-js-sdk/pull/4829)). Contributed by @t3chguy.
## ✨ Features
* Update to Node 22 LTS ([#4832](https://github.com/matrix-org/matrix-js-sdk/pull/4832)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Fix autodiscovery handling of 2xx (non-200) codes ([#4833](https://github.com/matrix-org/matrix-js-sdk/pull/4833)). Contributed by @t3chguy.
Changes in [37.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.5.0) (2025-05-06)
==================================================================================================
## ✨ Features
* Stabilise MSC3765 ([#4767](https://github.com/matrix-org/matrix-js-sdk/pull/4767)). Contributed by @Johennes.
* Inherit `methodFactory` extensions from the parent to the child loggers. ([#4809](https://github.com/matrix-org/matrix-js-sdk/pull/4809)). Contributed by @toger5.
## 🐛 Bug Fixes
* [Backport staging] Fix token refresh behaviour for non-expired tokens ([#4827](https://github.com/matrix-org/matrix-js-sdk/pull/4827)). Contributed by @RiotRobot.
* Refactor how token refreshing works to be more resilient ([#4819](https://github.com/matrix-org/matrix-js-sdk/pull/4819)). Contributed by @t3chguy.
Changes in [37.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.4.0) (2025-04-22)
==================================================================================================
## ✨ Features
+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

+1
View File
@@ -9,6 +9,7 @@ export default {
"src/crypto-api/index.ts",
"src/testing.ts",
"src/matrix.ts",
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
"scripts/**",
"spec/**",
// XXX: these look entirely unused
+10 -12
View File
@@ -1,16 +1,15 @@
{
"name": "matrix-js-sdk",
"version": "37.5.0-rc.0",
"version": "37.12.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=20.0.0"
"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.0.1",
"@matrix-org/olm": "3.2.15",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.0.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -81,14 +79,14 @@
"@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",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "10",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"babel-jest": "^29.0.0",
@@ -114,14 +112,14 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"knip": "^5.0.0",
"lint-staged": "^15.0.2",
"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",
"typedoc-plugin-coverage": "^3.0.0",
"typedoc-plugin-coverage": "^4.0.0",
"typedoc-plugin-mdn-links": "^5.0.0",
"typedoc-plugin-missing-exports": "^4.0.0",
"typescript": "^5.4.2"
-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";
+3 -1
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);
+5 -14
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,
@@ -59,7 +59,7 @@ import {
} from "../../../src/matrix";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import { defer, escapeRegExp } from "../../../src/utils";
import { escapeRegExp } from "../../../src/utils";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
import { flushPromises } from "../../test-utils/flushPromises";
import {
@@ -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 = "";
@@ -1283,13 +1274,13 @@ describe("crypto", () => {
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
// ... and finally, send the room key. We block the response until `sendRoomMessageDefer` completes.
const sendRoomMessageDefer = defer<FetchMock.MockResponse>();
const sendRoomMessageResolvers = Promise.withResolvers<FetchMock.MockResponse>();
const reqProm = new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
async (url: string, opts: RequestInit): Promise<FetchMock.MockResponse> => {
resolve(JSON.parse(opts.body as string));
return await sendRoomMessageDefer.promise;
return await sendRoomMessageResolvers.promise;
},
{
// append to the list of intercepts on this path (since we have some tests that call
@@ -1318,7 +1309,7 @@ describe("crypto", () => {
// release the send request
const resp = { event_id: "$event_id" };
sendRoomMessageDefer.resolve(resp);
sendRoomMessageResolvers.resolve(resp);
expect(await sendProm).toEqual(resp);
// still pending at this point
+3 -1
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");
+2 -3
View File
@@ -36,7 +36,6 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
import * as testData from "../../test-utils/test-data";
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
import { flushPromises } from "../../test-utils/flushPromises";
import { defer, type IDeferred } from "../../../src/utils";
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
@@ -861,7 +860,7 @@ describe("megolm-keys backup", () => {
expect(await aliceCrypto.getKeyBackupInfo()).toStrictEqual(testData.SIGNED_BACKUP_DATA);
// Delete the backup and we are expecting the key backup to be disabled
const keyBackupStatus = defer<boolean>();
const keyBackupStatus = Promise.withResolvers<boolean>();
aliceClient.once(CryptoEvent.KeyBackupStatus, (enabled) => keyBackupStatus.resolve(enabled));
await aliceCrypto.deleteKeyBackupVersion(testData.SIGNED_BACKUP_DATA.version!);
expect(await keyBackupStatus.promise).toBe(false);
@@ -1158,7 +1157,7 @@ describe("megolm-keys backup", () => {
// A check backup should happen at some point
await aliceCrypto.checkKeyBackupAndEnable();
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
const awaitHasQueriedNewBackup: PromiseWithResolvers<void> = Promise.withResolvers<void>();
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
+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");
});
});
});
+21 -19
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,
@@ -44,7 +46,7 @@ import {
type Verifier,
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { defer, escapeRegExp } from "../../../src/utils";
import { escapeRegExp } from "../../../src/utils";
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
@@ -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();
@@ -1540,10 +1542,10 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
*/
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
const mskRequestDefer = defer<string>();
const sskRequestDefer = defer<string>();
const uskRequestDefer = defer<string>();
const backupKeyRequestDefer = defer<string>();
const mskRequestResolvers = Promise.withResolvers<string>();
const sskRequestResolvers = Promise.withResolvers<string>();
const uskRequestResolvers = Promise.withResolvers<string>();
const backupKeyRequestResolvers = Promise.withResolvers<string>();
fetchMock.put(
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
@@ -1555,13 +1557,13 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
const name = content.name;
const requestId = content.request_id;
if (name == "m.cross_signing.user_signing") {
uskRequestDefer.resolve(requestId);
uskRequestResolvers.resolve(requestId);
} else if (name == "m.cross_signing.master") {
mskRequestDefer.resolve(requestId);
mskRequestResolvers.resolve(requestId);
} else if (name == "m.cross_signing.self_signing") {
sskRequestDefer.resolve(requestId);
sskRequestResolvers.resolve(requestId);
} else if (name == "m.megolm_backup.v1") {
backupKeyRequestDefer.resolve(requestId);
backupKeyRequestResolvers.resolve(requestId);
}
}
return {};
@@ -1570,10 +1572,10 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
);
const promiseMap = new Map<string, Promise<string>>();
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
promiseMap.set("m.cross_signing.master", mskRequestResolvers.promise);
promiseMap.set("m.cross_signing.self_signing", sskRequestResolvers.promise);
promiseMap.set("m.cross_signing.user_signing", uskRequestResolvers.promise);
promiseMap.set("m.megolm_backup.v1", backupKeyRequestResolvers.promise);
return promiseMap;
}
@@ -1604,7 +1606,7 @@ function sha256(commitmentStr: string): string {
return encodeUnpaddedBase64(createHash("sha256").update(commitmentStr, "utf8").digest());
}
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
function encodeUnpaddedBase64(uint8Array: ArrayLike<number>): string {
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
}
@@ -1638,7 +1640,7 @@ function buildReadyMessage(
}
/** build an m.key.verification.start to-device message suitable for the m.reciprocate.v1 flow, originating from the dummy device */
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayBuffer) {
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayLike<number>) {
return {
type: "m.key.verification.start",
content: {
+74
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";
@@ -1796,6 +1849,27 @@ describe("MatrixClient", function () {
expect(client.getUserIdLocalpart()).toBe("alice");
});
});
describe("setRoomMutePushRule", () => {
it("should set room push rule to muted", async () => {
const roomId = "!roomId:server";
const client = new MatrixClient({
baseUrl: "http://localhost",
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
});
client.pushRules = {
global: {
room: [{ rule_id: roomId, actions: [], default: false, enabled: false }],
},
};
const path = `/pushrules/global/room/${encodeURIComponent(roomId)}`;
httpBackend.when("DELETE", path).respond(200, {});
httpBackend.when("PUT", path).respond(200, {});
client.setRoomMutePushRule("global", roomId, true);
await httpBackend.flush("");
});
});
});
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
+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);
});
});
});
@@ -26,7 +26,6 @@ import {
PayloadType,
RendezvousError,
} from "../../../src/rendezvous";
import { defer } from "../../../src/utils";
import {
ClientPrefix,
DEVICE_CODE_SCOPE,
@@ -112,8 +111,8 @@ describe("MSC4108SignInWithQR", () => {
let opponentLogin: MSC4108SignInWithQR;
beforeEach(async () => {
let ourData = defer<string>();
let opponentData = defer<string>();
let ourData = Promise.withResolvers<string>();
let opponentData = Promise.withResolvers<string>();
const ourMockSession = {
send: jest.fn(async (newData) => {
@@ -122,7 +121,7 @@ describe("MSC4108SignInWithQR", () => {
receive: jest.fn(() => {
const prom = opponentData.promise;
prom.then(() => {
opponentData = defer();
opponentData = Promise.withResolvers();
});
return prom;
}),
@@ -141,7 +140,7 @@ describe("MSC4108SignInWithQR", () => {
receive: jest.fn(() => {
const prom = ourData.promise;
prom.then(() => {
ourData = defer();
ourData = Promise.withResolvers();
});
return prom;
}),
@@ -334,11 +333,11 @@ describe("MSC4108SignInWithQR", () => {
// @ts-ignore
await opponentLogin.receive();
const deferred = defer<IMyDevice>();
mocked(client.getDevice).mockReturnValue(deferred.promise);
const deviceResolvers = Promise.withResolvers<IMyDevice>();
mocked(client.getDevice).mockReturnValue(deviceResolvers.promise);
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
deferred.resolve({} as IMyDevice);
deviceResolvers.resolve({} as IMyDevice);
const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
+2 -3
View File
@@ -48,7 +48,6 @@ import { type SyncApiOptions, SyncState } from "../../src/sync";
import { type IStoredClientOpts } from "../../src";
import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils";
import { defer } from "../../src/utils";
import { KnownMembership } from "../../src/@types/membership";
import { type SyncCryptoCallbacks } from "../../src/common-crypto/CryptoBackend";
@@ -127,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;
@@ -369,7 +368,7 @@ describe("SlidingSyncSdk", () => {
});
it("can be created with live events", async () => {
const seenLiveEventDeferred = defer<boolean>();
const seenLiveEventDeferred = Promise.withResolvers<boolean>();
const listener = (
ev: MatrixEvent,
room?: Room,
-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
}
+8 -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);
});
}
+11 -11
View File
@@ -5,7 +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 { defer } from "../../src/utils";
import { logger } from "../../src/logger.ts";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
@@ -56,11 +56,11 @@ describe("onResumedSync", () => {
}
});
queue = new ToDeviceMessageQueue(mockClient);
queue = new ToDeviceMessageQueue(mockClient, logger);
});
it("resends queue after connectivity restored", async () => {
const deferred = defer();
const successResolvers = Promise.withResolvers<void>();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
@@ -73,15 +73,15 @@ describe("onResumedSync", () => {
onSendToDeviceSuccess = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
expect(store.removeToDeviceBatch).toHaveBeenCalled();
deferred.resolve();
successResolvers.resolve();
};
queue.start();
return deferred.promise;
return successResolvers.promise;
});
it("does not resend queue if client sync still catching up", async () => {
const deferred = defer();
const successResolvers = Promise.withResolvers<void>();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
@@ -89,15 +89,15 @@ describe("onResumedSync", () => {
resumeSync(SyncState.Catchup, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
deferred.resolve();
successResolvers.resolve();
};
queue.start();
return deferred.promise;
return successResolvers.promise;
});
it("does not resend queue if connectivity restored after queue stopped", async () => {
const deferred = defer();
const successResolvers = Promise.withResolvers<void>();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
@@ -107,10 +107,10 @@ describe("onResumedSync", () => {
resumeSync(SyncState.Syncing, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
deferred.resolve();
successResolvers.resolve();
};
queue.start();
return deferred.promise;
return successResolvers.promise;
});
});
+155 -18
View File
@@ -28,10 +28,12 @@ import {
WidgetApiToWidgetAction,
MatrixCapabilities,
type ITurnServer,
type IRoomEvent,
type IOpenIDCredentials,
type ISendEventFromWidgetResponseData,
WidgetApiResponseError,
UnstableApiVersion,
type ApiVersion,
type IRoomEvent,
} from "matrix-widget-api";
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
@@ -41,6 +43,9 @@ import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
import { MatrixEvent } from "../../src/models/event";
import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { sleep } from "../../src/utils";
import { SlidingSync } from "../../src/sliding-sync";
import { logger } from "../../src/logger";
import { flushPromises } from "../test-utils/flushPromises";
const testOIDCToken = {
access_token: "12345678",
@@ -50,6 +55,7 @@ const testOIDCToken = {
};
class MockWidgetApi extends EventEmitter {
public start = jest.fn().mockResolvedValue(undefined);
public getClientVersions = jest.fn();
public requestCapability = jest.fn().mockResolvedValue(undefined);
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
@@ -97,6 +103,15 @@ class MockWidgetApi extends EventEmitter {
send: jest.fn(),
sendComplete: jest.fn(),
};
/**
* This mocks the widget's view of what is supported by its environment.
* @param clientVersions The versions that the widget believes are supported by the host client's widget driver.
*/
public constructor(clientVersions: ApiVersion[]) {
super();
this.getClientVersions.mockResolvedValue(clientVersions);
}
}
declare module "../../src/types" {
@@ -118,7 +133,7 @@ describe("RoomWidgetClient", () => {
let client: MatrixClient;
beforeEach(() => {
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
widgetApi = new MockWidgetApi([UnstableApiVersion.MSC2762_UPDATE_STATE]) as unknown as MockedObject<WidgetApi>;
});
afterEach(() => {
@@ -129,6 +144,7 @@ describe("RoomWidgetClient", () => {
capabilities: ICapabilities,
sendContentLoaded: boolean | undefined = undefined,
userId?: string,
useSlidingSync?: boolean,
): Promise<void> => {
const baseUrl = "https://example.org";
client = createRoomWidgetClient(
@@ -140,7 +156,7 @@ describe("RoomWidgetClient", () => {
);
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
widgetApi.emit("ready");
await client.startClient();
await client.startClient(useSlidingSync ? { slidingSync: new SlidingSync("", new Map(), {}, client, 0) } : {});
};
describe("events", () => {
@@ -634,12 +650,20 @@ describe("RoomWidgetClient", () => {
});
it("receives", async () => {
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
// Client needs to be told that the room state is loaded
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),
);
await init;
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
// Let's assume that a state event comes in but it doesn't actually
// update the state of the room just yet (maybe it's unauthorized)
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
@@ -648,26 +672,139 @@ describe("RoomWidgetClient", () => {
// The client should've emitted about the received event
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
expect(await emittedSync).toEqual(SyncState.Syncing);
// It should've also inserted the event into the room object
// However it should not have changed the room state
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
// Now assume that the state event becomes favored by state
// resolution for whatever reason and enters into the current state
// of the room
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {
detail: { data: { state: [event] } },
}),
);
// Allow the getClientVersions promise to resolve
await flushPromises();
// It should now have changed the room state
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
});
it("backfills", async () => {
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
? [event as IRoomEvent]
: [],
describe("without support for update_state", () => {
beforeEach(() => {
widgetApi = new MockWidgetApi([]) as unknown as MockedObject<WidgetApi>;
});
it("receives", async () => {
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
);
// The client should've emitted about the received event
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
expect(await emittedSync).toEqual(SyncState.Syncing);
// It should've also inserted the event into the room object
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
});
it("does not receive with sliding sync (update_state is needed for sliding sync)", async () => {
await makeClient(
{ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] },
undefined,
undefined,
true,
);
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
const logSpy = jest.spyOn(logger, "error");
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
);
// The client should've emitted about the received event
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
expect(await emittedSync).toEqual(SyncState.Syncing);
// The incompatibility of sliding sync without update_state to get logged.
expect(logSpy).toHaveBeenCalledWith(
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
);
// It should not have inserted the event into the room object
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toEqual(null);
});
it("backfills", async () => {
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
? [event as IRoomEvent]
: [],
);
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
});
it("backfills with sliding sync", async () => {
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
? [event as IRoomEvent]
: [],
);
await makeClient(
{ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] },
undefined,
undefined,
true,
);
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
});
});
it("ignores state updates for other rooms", async () => {
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
// Client needs to be told that the room state is loaded
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),
);
await init;
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
// Now a room we're not interested in receives a state update
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {
detail: { data: { state: [{ ...event, room_id: "!other-room:example.org" }] } },
}),
);
// No change to the room state
for (const room of client.getRooms()) {
expect(room.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
}
});
});
+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"] });
});
});
});
+144 -10
View File
@@ -29,7 +29,7 @@ import {
Method,
} from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { defer, type QueryDict, sleep } from "../../../src/utils";
import { type QueryDict, sleep } from "../../../src/utils";
import { type Logger } from "../../../src/logger";
describe("FetchHttpApi", () => {
@@ -368,13 +368,22 @@ describe("FetchHttpApi", () => {
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
it("should only try to refresh the token once", async () => {
it("should not try to refresh the token if it has plenty of time left before expiry", async () => {
// We can't specify an expiry for the initial token, so this should:
// * Try once, fail
// * Attempt a refresh, get a token that's not expired
// * Try again, still fail
// * Not refresh the token because it's not expired
// ...which is TWO attempts and ONE refresh (which doesn't really
// count because it's only to get a token with an expiry)
const newAccessToken = "new-access-token";
const newRefreshToken = "new-refresh-token";
const tokenRefreshFunction = jest.fn().mockResolvedValue({
const tokenRefreshFunction = jest.fn().mockReturnValue({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiry: new Date(Date.now() + 1000),
// This needs to be sufficiently high that it's over the threshold for
// 'plenty of time' (which is a minute in practice).
expiry: new Date(Date.now() + 5 * 60 * 1000),
});
// fetch doesn't like our new or old tokens
@@ -394,7 +403,7 @@ describe("FetchHttpApi", () => {
unknownTokenErr,
);
// tried to refresh the token once
// tried to refresh the token once (to get the one with an expiry)
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
@@ -405,6 +414,54 @@ describe("FetchHttpApi", () => {
// logged out after refreshed access token is rejected
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
it("should try to refresh the token if it will expire soon", async () => {
const newAccessToken = "new-access-token";
const newRefreshToken = "new-refresh-token";
// first refresh is to get a token with an expiry at all, because we
// can't specify an expiry on the token we inject
const tokenRefreshFunction = jest.fn().mockResolvedValueOnce({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiry: new Date(Date.now() + 1000),
});
// next refresh is to return a token that will expire 'soon'
tokenRefreshFunction.mockResolvedValueOnce({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiry: new Date(Date.now() + 1000),
});
// ...and finally we return a token that has adequate time left
// so that it will cease retrying and fail the request.
tokenRefreshFunction.mockResolvedValueOnce({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiry: new Date(Date.now() + 5 * 60 * 1000),
});
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
tokenRefreshFunction,
accessToken,
refreshToken,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
// We should have seen the 3 token refreshes, as above.
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(tokenRefreshFunction).toHaveBeenCalledTimes(3);
});
});
});
});
@@ -464,12 +521,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, 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 });
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 () => {
jest.useFakeTimers();
const deferred = defer<Response>();
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
const responseResolvers = Promise.withResolvers<Response>();
const fetchFn = jest.fn().mockReturnValue(responseResolvers.promise);
const mockLogger = {
debug: jest.fn(),
} as unknown as Mocked<Logger>;
@@ -481,7 +615,7 @@ describe("FetchHttpApi", () => {
});
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
jest.advanceTimersByTime(1234);
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
responseResolvers.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
await prom;
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
@@ -500,7 +634,7 @@ describe("FetchHttpApi", () => {
});
it("should not make multiple concurrent refresh token requests", async () => {
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
const fetchFn = jest.fn().mockResolvedValue({
ok: false,
status: tokenInactiveError.httpStatus,
@@ -555,7 +689,7 @@ describe("FetchHttpApi", () => {
});
it("should use newly refreshed token if request starts mid-refresh", async () => {
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
const fetchFn = jest.fn().mockResolvedValue({
ok: false,
status: tokenInactiveError.httpStatus,
+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);
});
});
+4 -4
View File
@@ -73,7 +73,7 @@ import {
PolicyRecommendation,
PolicyScope,
} from "../../src/models/invites-ignorer";
import { defer, type QueryDict } from "../../src/utils";
import { type QueryDict } from "../../src/utils";
import { type SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
@@ -1997,8 +1997,8 @@ describe("MatrixClient", function () {
});
it("should cancel an event which is encrypting", async () => {
const encryptEventDefer = defer();
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
const encryptEventResolvers = Promise.withResolvers<void>();
mockCrypto.encryptEvent.mockReturnValue(encryptEventResolvers.promise);
const statusPromise = testUtils.emitPromise(event, "Event.status");
// @ts-ignore protected method access
@@ -2009,7 +2009,7 @@ describe("MatrixClient", function () {
assertCancelled();
// now let the encryption complete, and check that the message is not sent.
encryptEventDefer.resolve();
encryptEventResolvers.resolve();
await encryptAndSendPromise;
assertCancelled();
});
+100 -169
View File
@@ -16,11 +16,11 @@ 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 { 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" };
@@ -47,7 +47,7 @@ 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);
expect(sess?.memberships.length).toEqual(1);
@@ -74,7 +74,7 @@ describe("MatrixRTCSession", () => {
});
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);
expect(sess?.memberships.length).toEqual(0);
@@ -201,58 +201,6 @@ describe("MatrixRTCSession", () => {
});
});
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", () => {
it("returns the oldest membership event", () => {
jest.useFakeTimers();
@@ -320,28 +268,16 @@ 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();
@@ -368,71 +304,68 @@ 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", async () => {
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
jest.useRealTimers();
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
"m.mentions": { user_ids: [], room: true },
"notification_type": "ring",
"m.relates_to": {
event_id: ownMembershipId,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
},
"lifetime": 30000,
"sender_ts": expect.any(Number),
});
// Check if deprecated notify event is also sent.
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": "ring",
"call_id": "",
});
});
it("uses membershipExpiryTimeout from join config", async () => {
const realSetTimeout = setTimeout;
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus], mockFocus, { membershipExpiryTimeout: 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);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
const onMembershipsChanged = jest.fn();
@@ -443,13 +376,13 @@ describe("MatrixRTCSession", () => {
});
it("emits on membership changes", () => {
const mockRoom = makeMockRoom(membershipTemplate);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
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 +422,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;
@@ -569,24 +502,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);
@@ -635,18 +566,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);
@@ -697,9 +624,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,9 +649,7 @@ 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.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
@@ -773,10 +696,6 @@ describe("MatrixRTCSession", () => {
};
const mockRoom = makeMockRoom([member1, member2]);
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
@@ -806,6 +725,7 @@ describe("MatrixRTCSession", () => {
// update created_ts
member2.created_ts = 5000;
mockRoomState(mockRoom, [member1, member2]);
const keysSentPromise2 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
@@ -869,20 +789,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 +834,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}` }));
}
@@ -920,11 +854,7 @@ describe("MatrixRTCSession", () => {
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();
@@ -965,9 +895,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) => {
@@ -993,13 +921,16 @@ describe("MatrixRTCSession", () => {
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();
}
@@ -14,12 +14,10 @@ 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 { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
@@ -52,19 +50,16 @@ describe("MatrixRTCSessionManager", () => {
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];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
+133 -100
View File
@@ -1,6 +1,3 @@
/**
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
*/
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
@@ -27,44 +24,47 @@ 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 { defer } from "../../../src/utils";
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 } = defer();
/**
* 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.
*/
describe.each([
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
])("$description", ({ TestMembershipManager }) => {
describe("MembershipManager", () => {
let client: MockClient;
let room: Room;
const focusActive: LivekitFocusActive = {
@@ -81,7 +81,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);
@@ -95,12 +95,12 @@ describe.each([
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -111,13 +111,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);
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",
@@ -144,7 +144,7 @@ describe.each([
});
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);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
@@ -213,9 +213,9 @@ describe.each([
return Promise.reject(error);
});
});
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{
membershipServerSideExpiryTimeout: 9000,
delayedLeaveEventDelayMs: 9000,
},
room,
client,
@@ -276,7 +276,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);
manager.join([focus], focusActive);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
@@ -288,15 +288,15 @@ 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);
manager.join([focus], focusActive);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses membershipServerSideExpiryTimeout from config", () => {
const manager = new TestMembershipManager(
{ membershipServerSideExpiryTimeout: 123456 },
it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 123456 },
room,
client,
() => undefined,
@@ -312,13 +312,46 @@ describe.each([
});
});
it("uses membershipExpiryTimeout from config", async () => {
const manager = new TestMembershipManager(
{ membershipExpiryTimeout: 1234567 },
it("rejoins if delayed event is not found (404)", async () => {
const RESTART_DELAY = 15000;
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: RESTART_DELAY },
room,
client,
() => undefined,
);
// 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 MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
@@ -342,7 +375,7 @@ describe.each([
});
it("does nothing if join called when already joined", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
@@ -354,7 +387,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);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
@@ -362,7 +395,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);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
@@ -376,9 +409,8 @@ describe.each([
"_@alice:example.org_AAAAAAA",
);
});
// 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);
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -388,7 +420,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);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
@@ -404,11 +436,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);
@@ -423,7 +455,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);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
@@ -431,7 +463,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);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -439,7 +471,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);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
@@ -450,7 +482,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);
@@ -460,7 +495,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);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -480,9 +515,9 @@ describe.each([
// TODO: Not sure about this name
describe("background timers", () => {
it("sends only one keep-alive for delayed leave event per `membershipKeepAlivePeriod`", async () => {
const manager = new TestMembershipManager(
{ membershipKeepAlivePeriod: 10_000, membershipServerSideExpiryTimeout: 30_000 },
it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => {
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
room,
client,
() => undefined,
@@ -507,13 +542,13 @@ 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(
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
const manager = new MembershipManager(
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
room,
client,
() => undefined,
@@ -530,23 +565,23 @@ 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);
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);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -559,8 +594,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);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -576,7 +611,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);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -593,9 +628,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,
@@ -604,7 +638,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
@@ -621,11 +655,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);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -650,7 +683,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" },
@@ -660,7 +693,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
// Hit rate limit
@@ -681,8 +714,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(
@@ -693,7 +726,7 @@ describe.each([
new Headers({ "Retry-After": "2" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -701,8 +734,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(
@@ -713,7 +746,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -721,20 +754,20 @@ 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);
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(
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
const manager = new MembershipManager(
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
() => undefined,
@@ -750,12 +783,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);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -767,7 +800,7 @@ describe.each([
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);
@@ -778,5 +811,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,856 @@
/*
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);
});
});
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 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,12 +120,51 @@ 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);
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
});
it("enables room transport and disables to device transport on widget driver error", async () => {
mockClient.encryptAndSendToDevice.mockRejectedValue({
message:
"unknown variant `send_to_device`, expected one of `supported_api_versions`, `content_loaded`, `get_openid`, `org.matrix.msc2876.read_events`, `send_event`, `org.matrix.msc4157.update_delayed_event` at line 1 column 22",
});
transport.start();
const membership: ParticipantDeviceInfo = {
userId: "@alice:example.org",
deviceId: "ALICEDEVICE",
membershipTs: 1234,
};
const onTransportEnabled = jest.fn();
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
// We start with toDevice transport enabled
expect(transport.enabled.room).toBeFalsy();
expect(transport.enabled.toDevice).toBeTruthy();
await transport.sendKey("1235", 0, [membership]);
// We switched transport, now room transport is enabled
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
expect(transport.enabled.room).toBeTruthy();
expect(transport.enabled.toDevice).toBeFalsy();
// sanity check that we called the failang to device send key.
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledTimes(1);
// We re-sent the key via the room transport
expect(roomKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
expect(roomKeyTransport.sendKey).toHaveBeenCalledTimes(1);
mockClient.encryptAndSendToDevice.mockRestore();
});
it("does log that it did nothing when disabled", () => {
transport.start();
const onNewKeyFromTransport = jest.fn();
@@ -16,13 +16,12 @@ 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";
import { type Statistics } from "../../../src/matrixrtc";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
import { defer } from "../../../src/utils.ts";
import { type Logger } from "../../../src/logger.ts";
describe("ToDeviceKeyTransport", () => {
@@ -35,7 +34,7 @@ describe("ToDeviceKeyTransport", () => {
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
encryptAndSendToDevice: jest.fn(),
encryptAndSendToDevice: jest.fn().mockImplementation(() => Promise.resolve()),
});
mockLogger = {
debug: jest.fn(),
@@ -62,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);
@@ -101,6 +88,7 @@ describe("ToDeviceKeyTransport", () => {
call_id: "",
scope: "m.room",
},
sent_ts: expect.any(Number),
},
);
@@ -108,9 +96,14 @@ describe("ToDeviceKeyTransport", () => {
});
it("should emit when a key is received", async () => {
const deferred = defer<{ userId: string; deviceId: string; keyBase64Encoded: string; index: number }>();
const receivedKeyResolvers = Promise.withResolvers<{
userId: string;
deviceId: string;
keyBase64Encoded: string;
index: number;
}>();
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
deferred.resolve({ userId, deviceId, keyBase64Encoded, index });
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
});
transport.start();
@@ -136,7 +129,7 @@ describe("ToDeviceKeyTransport", () => {
}),
);
const { userId, deviceId, keyBase64Encoded, index } = await deferred.promise;
const { userId, deviceId, keyBase64Encoded, index } = await receivedKeyResolvers.promise;
expect(userId).toBe("@bob:example.org");
expect(deviceId).toBe("BOBDEVICE");
expect(keyBase64Encoded).toBe(testEncoded);
@@ -149,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 } {
+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);
});
});
});
+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.
+3 -4
View File
@@ -25,7 +25,6 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
import { type MatrixClient } from "../../src/client";
import { defer } from "../../src/utils";
import { Room } from "../../src/models/room";
import { KnownMembership } from "../../src/@types/membership";
import { DecryptionFailureCode } from "../../src/crypto-api";
@@ -1084,8 +1083,8 @@ describe("RoomState", function () {
timestamp: Date.now() + 1,
});
const deferred = defer<void>();
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
const decryptEventResolvers = Promise.withResolvers<void>();
mockClient.decryptEventIfNeeded.mockReturnValue(decryptEventResolvers.promise);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
@@ -1095,7 +1094,7 @@ describe("RoomState", function () {
// update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.event.content;
deferred.resolve();
decryptEventResolvers.resolve();
await prom;
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
@@ -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,7 +40,7 @@ import {
type UIAuthCallback,
} from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { defer } from "../../../src/utils";
import { logger } from "../../../src/logger.ts";
describe("OutgoingRequestProcessor", () => {
/** the OutgoingRequestProcessor implementation under test */
@@ -77,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 */
@@ -285,16 +285,16 @@ describe("OutgoingRequestProcessor", () => {
new RustSdkCryptoJs.DeviceId("TEST_DEVICE"),
);
const authRequestResultDefer = defer<string>();
const authRequestResultResolvers = Promise.withResolvers<string>();
const authRequestCalledPromise = new Promise<void>((resolve) => {
const mockHttpApi = {
authedRequest: async () => {
resolve();
return await authRequestResultDefer.promise;
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
@@ -308,7 +308,7 @@ describe("OutgoingRequestProcessor", () => {
olmMachine.close();
// the HTTP request completes...
authRequestResultDefer.resolve("{}");
authRequestResultResolvers.resolve("{}");
// ... and `makeOutgoingRequest` resolves satisfactorily
await result;
@@ -326,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
onlyData: true,
});
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
});
afterEach(() => {
@@ -20,7 +20,6 @@ import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
import { defer, type IDeferred } from "../../../src/utils";
import { logger } from "../../../src/logger";
describe("OutgoingRequestsManager", () => {
@@ -70,11 +69,11 @@ describe("OutgoingRequestsManager", () => {
const request2 = new RustSdkCryptoJs.KeysUploadRequest("foo2", "{}");
const request3 = new RustSdkCryptoJs.KeysBackupRequest("foo3", "{}", "1");
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
olmMachine.outgoingRequests
.mockImplementationOnce(async () => {
return firstOutgoingRequestDefer.promise;
return firstOutgoingRequestResolvers.promise;
})
.mockImplementationOnce(async () => {
return [request3];
@@ -87,7 +86,7 @@ describe("OutgoingRequestsManager", () => {
const thirdRequest = manager.doProcessOutgoingRequests();
// let the first request complete
firstOutgoingRequestDefer.resolve([request1, request2]);
firstOutgoingRequestResolvers.resolve([request1, request2]);
await firstRequest;
await secondRequest;
@@ -112,10 +111,10 @@ describe("OutgoingRequestsManager", () => {
const outgoingRequestCalledPromises: Promise<void>[] = [];
// deferreds which will provide the results of OlmMachine.outgoingRequests
const outgoingRequestResultDeferreds: IDeferred<OutgoingRequest[]>[] = [];
const outgoingRequestResultDeferreds: PromiseWithResolvers<OutgoingRequest[]>[] = [];
for (let i = 0; i < 3; i++) {
const resultDeferred = defer<OutgoingRequest[]>();
const resultDeferred = Promise.withResolvers<OutgoingRequest[]>();
const calledPromise = new Promise<void>((resolve) => {
olmMachine.outgoingRequests.mockImplementationOnce(() => {
resolve();
@@ -187,10 +186,10 @@ describe("OutgoingRequestsManager", () => {
it("When the manager is stopped after outgoingRequests() call, do not make sever requests", async () => {
const request1 = new RustSdkCryptoJs.KeysQueryRequest("foo", "{}");
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
return firstOutgoingRequestDefer.promise;
return firstOutgoingRequestResolvers.promise;
});
const firstRequest = manager.doProcessOutgoingRequests();
@@ -199,7 +198,7 @@ describe("OutgoingRequestsManager", () => {
manager.stop();
// let the first request complete
firstOutgoingRequestDefer.resolve([request1]);
firstOutgoingRequestResolvers.resolve([request1]);
await firstRequest;
@@ -210,7 +209,7 @@ describe("OutgoingRequestsManager", () => {
const request1 = new RustSdkCryptoJs.KeysQueryRequest("11", "{}");
const request2 = new RustSdkCryptoJs.KeysUploadRequest("12", "{}");
const firstRequestDefer = defer<void>();
const firstRequestResolvers = Promise.withResolvers<void>();
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
return [request1, request2];
@@ -219,7 +218,7 @@ describe("OutgoingRequestsManager", () => {
processor.makeOutgoingRequest
.mockImplementationOnce(async () => {
manager.stop();
return firstRequestDefer.promise;
return firstRequestResolvers.promise;
})
.mockImplementationOnce(async () => {
return;
@@ -227,7 +226,7 @@ describe("OutgoingRequestsManager", () => {
const firstRequest = manager.doProcessOutgoingRequests();
firstRequestDefer.resolve();
firstRequestResolvers.resolve();
await firstRequest;
@@ -21,7 +21,6 @@ import fetchMock from "fetch-mock-jest";
import { PerSessionKeyBackupDownloader } from "../../../src/rust-crypto/PerSessionKeyBackupDownloader";
import { logger } from "../../../src/logger";
import { defer, type IDeferred } from "../../../src/utils";
import {
type RustBackupCryptoEventMap,
type RustBackupCryptoEvents,
@@ -57,15 +56,15 @@ describe("PerSessionKeyBackupDownloader", () => {
let mockOlmMachine: Mocked<OlmMachine>;
let mockBackupDecryptor: Mocked<BackupDecryptor>;
let expectedSession: { [roomId: string]: { [sessionId: string]: IDeferred<void> } };
let expectedSession: { [roomId: string]: { [sessionId: string]: PromiseWithResolvers<void> } };
function expectSessionImported(roomId: string, sessionId: string) {
const deferred = defer<void>();
const sessionImportedResolvers = Promise.withResolvers<void>();
if (!expectedSession[roomId]) {
expectedSession[roomId] = {};
}
expectedSession[roomId][sessionId] = deferred;
return deferred.promise;
expectedSession[roomId][sessionId] = sessionImportedResolvers;
return sessionImportedResolvers.promise;
}
function mockClearSession(sessionId: string): Mocked<IMegolmSessionData> {
@@ -115,9 +114,9 @@ describe("PerSessionKeyBackupDownloader", () => {
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
const roomId = keys[0].room_id;
const sessionId = keys[0].session_id;
const deferred = expectedSession[roomId] && expectedSession[roomId][sessionId];
if (deferred) {
deferred.resolve();
const sessionImportedResolvers = expectedSession[roomId] && expectedSession[roomId][sessionId];
if (sessionImportedResolvers) {
sessionImportedResolvers.resolve();
}
});
@@ -143,7 +142,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
it("Should download and import a missing key from backup", async () => {
const awaitKeyImported = defer<void>();
const awaitKeyImported = Promise.withResolvers<void>();
const roomId = "!roomId";
const sessionId = "sessionId";
const expectAPICall = new Promise<void>((resolve) => {
@@ -168,14 +167,14 @@ describe("PerSessionKeyBackupDownloader", () => {
});
it("Should not hammer the backup if the key is requested repeatedly", async () => {
const blockOnServerRequest = defer<void>();
const blockOnServerRequest = Promise.withResolvers<void>();
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/!roomId/:session_id`, async (url, request) => {
await blockOnServerRequest.promise;
return [mockCipherKey];
});
const awaitKey2Imported = defer<void>();
const awaitKey2Imported = Promise.withResolvers<void>();
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
if (keys[0].session_id === "sessionId2") {
@@ -267,8 +266,8 @@ describe("PerSessionKeyBackupDownloader", () => {
it("Should stop properly", async () => {
// Simulate a call to stop while request is in flight
const blockOnServerRequest = defer<void>();
const requestRoomKeyCalled = defer<void>();
const blockOnServerRequest = Promise.withResolvers<void>();
const requestRoomKeyCalled = Promise.withResolvers<void>();
// Mock the request to block
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, async (url, request) => {
@@ -490,7 +489,7 @@ describe("PerSessionKeyBackupDownloader", () => {
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const rateDeferred = defer<void>();
const rateDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
// @ts-ignore
@@ -544,7 +543,7 @@ describe("PerSessionKeyBackupDownloader", () => {
// @ts-ignore
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const errorDeferred = defer<void>();
const errorDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
// @ts-ignore
@@ -588,7 +587,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
it("On Unknown error on import skip the key and continue", async () => {
const keyImported = defer<void>();
const keyImported = Promise.withResolvers<void>();
mockRustBackupManager.importBackedUpRoomKeys
.mockImplementationOnce(async () => {
throw new Error("Didn't work");
+6 -5
View File
@@ -29,7 +29,6 @@ import { type Mocked } from "jest-mock";
import { HistoryVisibility, type MatrixEvent, type Room, type RoomMember } from "../../../src";
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
import { type KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
import { defer } from "../../../src/utils";
import { type OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
import { KnownMembership } from "../../../src/@types/membership";
import {
@@ -37,6 +36,7 @@ import {
AllDevicesIsolationMode,
OnlySignedDevicesIsolationMode,
} from "../../../src/crypto-api";
import { logger } from "../../../src/logger.ts";
describe("RoomEncryptor", () => {
describe("History Visibility", () => {
@@ -109,6 +109,7 @@ describe("RoomEncryptor", () => {
} as unknown as Mocked<Room>;
roomEncryptor = new RoomEncryptor(
logger,
mockOlmMachine,
mockKeyClaimManager,
mockOutgoingRequestManager,
@@ -120,8 +121,8 @@ describe("RoomEncryptor", () => {
const defaultDevicesIsolationMode = new AllDevicesIsolationMode(false);
it("should ensure that there is only one shareRoomKey at a time", async () => {
const deferredShare = defer<void>();
const insideOlmShareRoom = defer<void>();
const deferredShare = Promise.withResolvers<void>();
const insideOlmShareRoom = Promise.withResolvers<void>();
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
insideOlmShareRoom.resolve();
@@ -149,8 +150,8 @@ describe("RoomEncryptor", () => {
// Regression test for https://github.com/element-hq/element-web/issues/26684
it("Should maintain order of encryption requests", async () => {
const firstTargetMembers = defer<void>();
const secondTargetMembers = defer<void>();
const firstTargetMembers = Promise.withResolvers<void>();
const secondTargetMembers = Promise.withResolvers<void>();
mockOlmMachine.shareRoomKey.mockResolvedValue([]);
+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);
});
+30 -20
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,
@@ -68,8 +70,6 @@ import {
import * as testData from "../../test-utils/test-data";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { defer } from "../../../src/utils";
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";
@@ -118,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"],
@@ -141,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"],
@@ -163,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"],
@@ -187,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,
@@ -252,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(),
@@ -359,11 +363,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,
@@ -401,6 +405,7 @@ describe("initRustCrypto", () => {
);
const PICKLE_KEY = "pickle1234";
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
@@ -552,11 +557,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[] = [
@@ -577,7 +582,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 () => {
@@ -814,7 +819,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,
@@ -899,6 +904,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(
@@ -928,8 +934,8 @@ describe("RustCrypto", () => {
it("should go round the loop again if another sync completes while the first `outgoingRequests` is running", async () => {
// the first call to `outgoingMessages` will return a promise which blocks for a while
const firstOutgoingRequestsDefer = defer<Array<any>>();
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsDefer.promise);
const firstOutgoingRequestsResolvers = Promise.withResolvers<Array<any>>();
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsResolvers.promise);
// the second will return a KeysQueryRequest.
const testReq = new KeysQueryRequest("1234", "{}");
@@ -946,7 +952,7 @@ describe("RustCrypto", () => {
// the first call now completes, *with an empty result*, which would normally cause us to exit the loop, but
// we should have a second call queued. It should trigger a call to `makeOutgoingRequest`.
firstOutgoingRequestsDefer.resolve([]);
firstOutgoingRequestsResolvers.resolve([]);
await awaitCallToMakeOutgoingRequest();
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
});
@@ -958,7 +964,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
@@ -1016,7 +1026,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,
@@ -1229,7 +1239,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,
@@ -1472,7 +1482,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,
@@ -1559,7 +1569,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,
@@ -1631,7 +1641,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,
@@ -2372,7 +2382,7 @@ async function makeTestRustCrypto(
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto({
logger,
logger: new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec")),
http,
userId,
deviceId,
+8 -1
View File
@@ -32,6 +32,7 @@ import {
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { type IDeviceKeys } from "../../../src/@types/crypto";
import { EventType, MatrixEvent, MsgType } from "../../../src";
import { logger } from "../../../src/logger.ts";
describe("VerificationRequest", () => {
describe("pending", () => {
@@ -147,6 +148,7 @@ describe("VerificationRequest", () => {
methods,
);
const aliceVerificationRequest = new RustVerificationRequest(
logger,
aliceOlmMachine,
innerVerificationRequest,
aliceRequestLoop as unknown as OutgoingRequestProcessor,
@@ -174,6 +176,7 @@ describe("VerificationRequest", () => {
"$m.key.verification.request",
)!;
const bobVerificationRequest = new RustVerificationRequest(
logger,
bobOlmMachine,
bobInnerVerificationRequest,
bobRequestLoop as unknown as OutgoingRequestProcessor,
@@ -278,6 +281,7 @@ describe("VerificationRequest", () => {
methods,
);
const aliceVerificationRequest = new RustVerificationRequest(
logger,
aliceOlmMachine,
innerVerificationRequest,
aliceRequestLoop as unknown as OutgoingRequestProcessor,
@@ -305,6 +309,7 @@ describe("VerificationRequest", () => {
"$m.key.verification.request",
)!;
const bobVerificationRequest = new RustVerificationRequest(
logger,
bobOlmMachine,
bobInnerVerificationRequest,
bobRequestLoop as unknown as OutgoingRequestProcessor,
@@ -392,6 +397,7 @@ describe("VerificationRequest", () => {
methods,
);
const aliceVerificationRequest = new RustVerificationRequest(
logger,
aliceOlmMachine,
innerVerificationRequest,
aliceRequestLoop as unknown as OutgoingRequestProcessor,
@@ -419,6 +425,7 @@ describe("VerificationRequest", () => {
"$m.key.verification.request",
)!;
const bobVerificationRequest = new RustVerificationRequest(
logger,
bobOlmMachine,
bobInnerVerificationRequest,
bobRequestLoop as unknown as OutgoingRequestProcessor,
@@ -496,7 +503,7 @@ function makeTestRequest(
inner ??= makeMockedInner();
olmMachine ??= {} as RustSdkCryptoJs.OlmMachine;
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
return new RustVerificationRequest(olmMachine, inner, outgoingRequestProcessor, []);
return new RustVerificationRequest(logger, olmMachine, inner, outgoingRequestProcessor, []);
}
/** Mock up a rust-side VerificationRequest */
+10 -11
View File
@@ -1,7 +1,6 @@
// This file had a function whose name is all caps, which displeases eslint
/* eslint new-cap: "off" */
import { defer, type IDeferred } from "../../src/utils";
import { MatrixError } from "../../src/http-api";
import { MatrixScheduler } from "../../src/scheduler";
import * as utils from "../test-utils/test-utils";
@@ -14,7 +13,7 @@ describe("MatrixScheduler", function () {
let scheduler: MatrixScheduler<Record<string, boolean>>;
let retryFn: ((event: MatrixEvent | null, attempt: number, err: MatrixError) => number) | null;
let queueFn: ((event: MatrixEvent) => string | null) | null;
let deferred: IDeferred<Record<string, boolean>>;
let deferred: PromiseWithResolvers<Record<string, boolean>>;
const roomId = "!foo:bar";
const eventA = utils.mkMessage({
user: "@alice:bar",
@@ -44,7 +43,7 @@ describe("MatrixScheduler", function () {
);
retryFn = null;
queueFn = null;
deferred = defer();
deferred = Promise.withResolvers();
});
it("should process events in a queue in a FIFO manner", async function () {
@@ -54,8 +53,8 @@ describe("MatrixScheduler", function () {
queueFn = function () {
return "one_big_queue";
};
const deferA = defer<Record<string, boolean>>();
const deferB = defer<Record<string, boolean>>();
const deferA = Promise.withResolvers<Record<string, boolean>>();
const deferB = Promise.withResolvers<Record<string, boolean>>();
let yieldedA = false;
scheduler.setProcessFunction(function (event) {
if (yieldedA) {
@@ -79,9 +78,9 @@ describe("MatrixScheduler", function () {
it("should invoke the retryFn on failure and wait the amount of time specified", async function () {
const waitTimeMs = 1500;
const retryDefer = defer();
const retryResolvers = Promise.withResolvers<void>();
retryFn = function () {
retryDefer.resolve();
retryResolvers.resolve();
return waitTimeMs;
};
queueFn = function () {
@@ -109,7 +108,7 @@ describe("MatrixScheduler", function () {
await Promise.resolve();
expect(procCount).toEqual(1);
deferred.reject({});
await retryDefer.promise;
await retryResolvers.promise;
expect(procCount).toEqual(1);
jest.advanceTimersByTime(waitTimeMs);
await Promise.resolve();
@@ -127,8 +126,8 @@ describe("MatrixScheduler", function () {
return "yep";
};
const deferA = defer<Record<string, boolean>>();
const deferB = defer<Record<string, boolean>>();
const deferA = Promise.withResolvers<Record<string, boolean>>();
const deferB = Promise.withResolvers<Record<string, boolean>>();
let procCount = 0;
scheduler.setProcessFunction(function (ev) {
procCount += 1;
@@ -180,7 +179,7 @@ describe("MatrixScheduler", function () {
};
const expectOrder = [eventA.getId(), eventB.getId(), eventD.getId()];
const deferA = defer<Record<string, boolean>>();
const deferA = Promise.withResolvers<Record<string, boolean>>();
const allExpectedEventsSeenInOrderPromise = new Promise((resolve) => {
scheduler.setProcessFunction(function (event) {
const id = expectOrder.shift();
+2 -3
View File
@@ -30,7 +30,6 @@ import {
import { secureRandomString } from "../../src/randomstring";
import { type SecretInfo } from "../../src/secret-storage.ts";
import { type AccountDataEvents, ClientEvent, MatrixEvent, TypedEventEmitter } from "../../src";
import { defer, type IDeferred } from "../../src/utils";
declare module "../../src/@types/event" {
interface SecretStorageAccountDataEvents {
@@ -289,10 +288,10 @@ describe("ServerSideSecretStorageImpl", function () {
describe("setDefaultKeyId", function () {
let secretStorage: ServerSideSecretStorage;
let accountDataAdapter: Mocked<AccountDataClient>;
let accountDataPromise: IDeferred<void>;
let accountDataPromise: PromiseWithResolvers<void>;
beforeEach(() => {
accountDataAdapter = mockAccountDataClient();
accountDataPromise = defer();
accountDataPromise = Promise.withResolvers();
accountDataAdapter.setAccountData.mockImplementation(() => {
accountDataPromise.resolve();
return Promise.resolve({});
@@ -18,7 +18,6 @@ import "fake-indexeddb/auto";
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
import { defer } from "../../../src/utils";
function setupWorker(worker: IndexedDBStoreWorker): void {
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
@@ -27,16 +26,16 @@ function setupWorker(worker: IndexedDBStoreWorker): void {
describe("IndexedDBStore Worker", () => {
it("should pass 'closed' event via postMessage", async () => {
const deferred = defer<void>();
const postMessageSuccessResolvers = Promise.withResolvers<void>();
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
if (seq === 1 && command === "cmd_success") {
deferred.resolve();
postMessageSuccessResolvers.resolve();
}
});
const worker = new IndexedDBStoreWorker(postMessage);
setupWorker(worker);
await deferred.promise;
await postMessageSuccessResolvers.promise;
// @ts-ignore - private field access
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
+18 -19
View File
@@ -21,7 +21,6 @@ import { IDBFactory } from "fake-indexeddb";
import { IndexedDBStore, type IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { defer } from "../../../src/utils";
describe("IndexedDBStore", () => {
afterEach(() => {
@@ -80,7 +79,7 @@ describe("IndexedDBStore", () => {
it("Should load presence events on startup", async () => {
// 1. Create idb database
const indexedDB = new IDBFactory();
const setupDefer = defer<Event>();
const setupResolvers = Promise.withResolvers<Event>();
const req = indexedDB.open("matrix-js-sdk:db3", 1);
let db: IDBDatabase;
req.onupgradeneeded = () => {
@@ -89,11 +88,11 @@ describe("IndexedDBStore", () => {
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = setupDefer.resolve;
await setupDefer.promise;
req.onsuccess = setupResolvers.resolve;
await setupResolvers.promise;
// 2. Fill in user presence data
const writeDefer = defer<Event>();
const writeResolvers = Promise.withResolvers<Event>();
const transaction = db!.transaction(["users"], "readwrite");
const objectStore = transaction.objectStore("users");
const request = objectStore.put({
@@ -106,8 +105,8 @@ describe("IndexedDBStore", () => {
type: "m.presence",
},
});
request.onsuccess = writeDefer.resolve;
await writeDefer.promise;
request.onsuccess = writeResolvers.resolve;
await writeResolvers.promise;
// 3. Close database
req.result.close();
@@ -201,7 +200,7 @@ describe("IndexedDBStore", () => {
});
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
const deferred = defer<Event>();
const requestSuccessResolvers = Promise.withResolvers<Event>();
// seed db3 to Version 1 so it forces a migration
const req = indexedDB.open("matrix-js-sdk:db3", 1);
req.onupgradeneeded = () => {
@@ -210,8 +209,8 @@ describe("IndexedDBStore", () => {
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = deferred.resolve;
await deferred.promise;
req.onsuccess = requestSuccessResolvers.resolve;
await requestSuccessResolvers.promise;
req.result.close();
const store = new IndexedDBStore({
@@ -232,20 +231,20 @@ describe("IndexedDBStore", () => {
});
await store.startup();
const deferred = defer<void>();
store.on("closed", deferred.resolve);
const storeClosedResolvers = Promise.withResolvers<void>();
store.on("closed", storeClosedResolvers.resolve);
// @ts-ignore - private field access
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
await deferred.promise;
await storeClosedResolvers.promise;
});
it("should use remote backend if workerFactory passed", async () => {
const deferred = defer<void>();
const workerPostMessageResolvers = Promise.withResolvers<void>();
class MockWorker {
postMessage(data: any) {
if (data.command === "setupWorker") {
deferred.resolve();
workerPostMessageResolvers.resolve();
}
}
}
@@ -257,7 +256,7 @@ describe("IndexedDBStore", () => {
workerFactory: () => new MockWorker() as Worker,
});
store.startup();
await deferred.promise;
await workerPostMessageResolvers.promise;
});
it("remote worker should pass closed event", async () => {
@@ -273,10 +272,10 @@ describe("IndexedDBStore", () => {
});
store.startup();
const deferred = defer<void>();
store.on("closed", deferred.resolve);
const storeClosedResolvers = Promise.withResolvers<void>();
store.on("closed", storeClosedResolvers.resolve);
(worker as any).onmessage({ data: { command: "closed" } });
await deferred.promise;
await storeClosedResolvers.promise;
});
it("remote worker should pass command failures", async () => {
+8 -1
View File
@@ -51,7 +51,11 @@ import {
type SDPStreamMetadata,
type SDPStreamMetadataKey,
} from "../webrtc/callEventTypes.ts";
import { type EncryptionKeysEventContent, type ICallNotifyContent } from "../matrixrtc/types.ts";
import {
type IRTCNotificationContent,
type EncryptionKeysEventContent,
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
@@ -147,6 +151,7 @@ export enum EventType {
// MatrixRTC events
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
}
export enum RelationType {
@@ -158,6 +163,7 @@ export enum RelationType {
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
Thread = "m.thread",
unstable_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent",
}
export enum MsgType {
@@ -325,6 +331,7 @@ export interface TimelineEvents {
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
+7 -31
View File
@@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// this is needed to tell TS about globalThis.Olm
import "@matrix-org/olm";
export {};
declare global {
@@ -34,39 +31,18 @@ declare global {
}
}
interface MediaDevices {
// This is experimental and types don't know about it yet
// https://github.com/microsoft/TypeScript/issues/33232
getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
// Chrome-specific getUserMedia constraints
interface MediaTrackConstraints {
mandatory?: {
chromeMediaSource: string;
chromeMediaSourceId: string;
};
}
interface DesktopCapturerConstraints {
audio:
| boolean
| {
mandatory: {
chromeMediaSource: string;
chromeMediaSourceId: string;
};
};
video:
| boolean
| {
mandatory: {
chromeMediaSource: string;
chromeMediaSourceId: string;
};
};
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface DummyInterfaceWeShouldntBeUsingThis {}
interface Navigator {
// We check for the webkit-prefixed getUserMedia to detect if we're
// on webkit: we should check if we still need to do this
webkitGetUserMedia?: DummyInterfaceWeShouldntBeUsingThis;
webkitGetUserMedia?: unknown;
}
export interface Uint8ArrayToBase64Options {
+9 -3
View File
@@ -28,9 +28,7 @@ import { type EventType, type RelationType, type RoomType } from "./event.ts";
export interface IJoinRoomOpts {
/**
* True to do a room initial sync on the resulting
* room. If false, the <strong>returned Room object will have no current state.
* </strong> Default: true.
* @deprecated does nothing
*/
syncRoom?: boolean;
@@ -45,6 +43,14 @@ export interface IJoinRoomOpts {
viaServers?: string[];
}
/** Options object for {@link MatrixClient.invite}. */
export interface InviteOpts {
/**
* The reason for the invite.
*/
reason?: string;
}
export interface KnockRoomOpts {
/**
* The reason for the knock.
+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();
}
};
+1 -1
View File
@@ -462,7 +462,7 @@ export class AutoDiscovery {
};
}
if (!response.ok) {
if (response.status !== 200) {
return {
raw: {},
action: AutoDiscoveryAction.FAIL_PROMPT,
+188 -156
View File
@@ -52,7 +52,7 @@ import {
type GroupCallEventHandlerEventHandlerMap,
} from "./webrtc/groupCallEventHandler.ts";
import * as utils from "./utils.ts";
import { deepCompare, defer, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
import { deepCompare, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
import { Direction, EventTimeline } from "./models/event-timeline.ts";
import { type IActionsObject, PushProcessor } from "./pushprocessor.ts";
import { AutoDiscovery, type AutoDiscoveryAction } from "./autodiscovery.ts";
@@ -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,
@@ -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;
}
@@ -883,76 +889,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 +971,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 +999,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 +1014,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,8 +1025,69 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
ToDeviceEvent = "toDeviceEvent",
/**
* 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;
* });
*/
ReceivedToDeviceMessage = "receivedToDeviceMessage",
/**
* Fires whenever new user-scoped account_data is added.
* 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){
* myAccountData[event.type] = event.content;
* });
* ```
*/
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>
*
* The payload is the newly created room, fully populated.
* @example
* ```
* matrixClient.on("Room", function(room){
* var roomId = room.roomId;
* });
* ```
*/
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>
* The payload is the roomId of the deleted room.
* @example
* ```
* matrixClient.on("deleteRoom", function(roomId){
* // update UI from getRooms()
* });
* ```
*/
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
/**
* Fires when the client .well-known info is fetched.
* The payload is the JSON object (see {@link IClientWellKnown}) returned by the server
*/
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
/**
* @deprecated This event is not supported anymore.
*
* 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
@@ -1097,52 +1096,78 @@ export type ClientEventHandlerMap = {
*
* An undecryptable to-device event is therefore likely to indicate problems.
*
* @param event - The undecyptable to-device event
* The payload is the undecyptable to-device 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 = {
[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.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
/**
* 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.
* @example
* ```
* matrixClient.on("accountData", function(event, oldEvent){
* myAccountData[event.type] = event.content;
* });
* ```
*/
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
/**
* 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.
* @example
* ```
* matrixClient.on("Room", function(room){
* var roomId = room.roomId;
* });
* ```
*/
[ClientEvent.Room]: (room: Room) => void;
/**
* 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.
* @example
* ```
* matrixClient.on("deleteRoom", function(roomId){
* // update UI from getRooms()
* });
* ```
*/
[ClientEvent.DeleteRoom]: (roomId: string) => void;
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
/**
* Fires when the client .well-known info is fetched.
*
* @param data - The JSON object returned by the server
*/
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
@@ -1341,9 +1366,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 +1390,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 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
return this.canResetTimelineCallback(roomId);
},
logger: this.logger.getChild("sync"),
};
}
@@ -1527,9 +1553,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 +1583,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 +1932,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 +1947,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async initRustCrypto(
args: {
useIndexedDB?: boolean;
cryptoDatabasePrefix?: string;
storageKey?: Uint8Array;
storagePassword?: string;
} = {},
@@ -1950,7 +1984,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,
@@ -2176,7 +2210,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
): Promise<EmptyObject> {
// If the sync loop is not running, fall back to setAccountDataRaw.
if (!this.clientRunning) {
logger.warn(
this.logger.warn(
"Calling `setAccountData` before the client is started: `getAccountData` may return inconsistent results.",
);
return await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
@@ -2191,7 +2225,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (existingData && deepCompare(existingData.event.content, content)) return {};
// Create a promise which will resolve when the update is received
const updatedDefer = defer<void>();
const updatedResolvers = Promise.withResolvers<void>();
function accountDataListener(event: MatrixEvent): void {
// Note that we cannot safely check that the content matches what we expected, because there is a race:
// * We set the new content
@@ -2203,13 +2237,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
//
// Anyway, what we *shouldn't* do is get stuck in a loop. I think the best we can do is check that the event
// type matches.
if (event.getType() === eventType) updatedDefer.resolve();
if (event.getType() === eventType) updatedResolvers.resolve();
}
this.addListener(ClientEvent.AccountData, accountDataListener);
try {
const result = await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
await updatedDefer.promise;
await updatedResolvers.promise;
return result;
} finally {
this.removeListener(ClientEvent.AccountData, accountDataListener);
@@ -2336,10 +2370,6 @@ 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;
@@ -2375,12 +2405,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);
}
/**
@@ -3731,12 +3756,19 @@ 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 invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
if (typeof opts != "object") {
opts = { reason: opts };
}
return this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
}
/**
@@ -5173,24 +5205,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} else if (!hasDontNotifyRule) {
// Remove the existing one before setting the mute push rule
// This is a workaround to SYN-590 (Push rule update fails)
const deferred = utils.defer();
const doneResolvers = Promise.withResolvers<void>();
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
.then(() => {
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
actions: [PushRuleActionName.DontNotify],
})
.then(() => {
deferred.resolve();
doneResolvers.resolve();
})
.catch((err) => {
deferred.reject(err);
doneResolvers.reject(err);
});
})
.catch((err) => {
deferred.reject(err);
doneResolvers.reject(err);
});
promise = deferred.promise;
promise = doneResolvers.promise;
}
}
@@ -7295,7 +7327,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);
}
+5 -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";
@@ -96,9 +96,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.
+21
View File
@@ -1365,6 +1365,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";
+89 -47
View File
@@ -28,6 +28,8 @@ import {
type WidgetApiAction,
type IWidgetApiResponse,
type IWidgetApiResponseData,
type IUpdateStateToWidgetActionRequest,
UnstableApiVersion,
} from "matrix-widget-api";
import { MatrixEvent, type IEvent, type IContent, EventStatus } from "./models/event.ts";
@@ -135,6 +137,7 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: ()
export class RoomWidgetClient extends MatrixClient {
private room?: Room;
private readonly widgetApiReady: Promise<void>;
private readonly roomStateSynced: Promise<void>;
private lifecycle?: AbortController;
private syncState: SyncState | null = null;
@@ -188,6 +191,11 @@ export class RoomWidgetClient extends MatrixClient {
};
this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
this.roomStateSynced = capabilities.receiveState?.length
? new Promise<void>((resolve) =>
this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve),
)
: Promise.resolve();
// Request capabilities for the functionality this client needs to support
if (
@@ -240,6 +248,7 @@ export class RoomWidgetClient extends MatrixClient {
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
widgetApi.on(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
// Open communication with the host
widgetApi.start();
@@ -251,6 +260,10 @@ export class RoomWidgetClient extends MatrixClient {
if (sendContentLoaded) widgetApi.sendContentLoaded();
}
public async supportUpdateState(): Promise<boolean> {
return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
}
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
this.lifecycle = new AbortController();
@@ -275,27 +288,34 @@ export class RoomWidgetClient extends MatrixClient {
await this.widgetApiReady;
// Backfill the requested events
// We only get the most recent event for every type + state key combo,
// so it doesn't really matter what order we inject them in
await Promise.all(
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
// sync room state:
if (await this.supportUpdateState()) {
// This will resolve once the client driver has sent us all the allowed room state.
await this.roomStateSynced;
} else {
// Backfill the requested events
// We only get the most recent event for every type + state key combo,
// so it doesn't really matter what order we inject them in
await Promise.all(
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [
this.roomId,
]);
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
if (this.syncApi instanceof SyncApi) {
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
// -> state events in `timelineEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
} else {
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
}
events.forEach((event) => {
this.emit(ClientEvent.Event, event);
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
});
}) ?? [],
);
if (this.syncApi instanceof SyncApi) {
// Passing events as `stateAfterEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
} else {
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
}
events.forEach((event) => {
this.emit(ClientEvent.Event, event);
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
});
}) ?? [],
);
}
if (opts.clientWellKnownPollPeriod !== undefined) {
this.clientWellKnownIntervalID = setInterval(() => {
@@ -303,9 +323,8 @@ export class RoomWidgetClient extends MatrixClient {
}, 1000 * opts.clientWellKnownPollPeriod);
this.fetchClientWellKnown();
}
this.setSyncState(SyncState.Syncing);
logger.info("Finished backfilling events");
logger.info("Finished initial sync");
this.matrixRTC.start();
@@ -316,6 +335,7 @@ export class RoomWidgetClient extends MatrixClient {
public stopClient(): void {
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
super.stopClient();
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
@@ -600,36 +620,28 @@ export class RoomWidgetClient extends MatrixClient {
// Only inject once we have update the txId
await this.updateTxId(event);
// The widget API does not tell us whether a state event came from `state_after` or not so we assume legacy behaviour for now.
if (this.syncApi instanceof SyncApi) {
// The code will want to be something like:
// ```
// if (!params.addToTimeline && !params.addToState) {
// // Passing undefined for `stateAfterEventList` makes `injectRoomEvents` run in "legacy mode"
// // -> state events part of the `timelineEventList` parameter will update the state.
// this.injectRoomEvents(this.room!, [], undefined, [event]);
// } else {
// this.injectRoomEvents(this.room!, undefined, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
// }
// ```
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
// -> state events in `timelineEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
if (await this.supportUpdateState()) {
await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
} else {
// Passing undefined for `stateAfterEventList` will make `injectRoomEvents` run in legacy mode
// -> state events in `timelineEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
}
} else {
// The code will want to be something like:
// ```
// if (!params.addToTimeline && !params.addToState) {
// this.injectRoomEvents(this.room!, [], [event]);
// } else {
// this.injectRoomEvents(this.room!, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
// }
// ```
await this.syncApi!.injectRoomEvents(this.room!, [], [event]); // Sliding Sync
// Sliding Sync
if (await this.supportUpdateState()) {
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
} else {
logger.error(
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
);
}
}
this.emit(ClientEvent.Event, event);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
logger.info(`Received event ${event.getId()} ${event.getType()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
@@ -654,6 +666,36 @@ export class RoomWidgetClient extends MatrixClient {
await this.ack(ev);
};
private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => {
ev.preventDefault();
if (!(await this.supportUpdateState())) {
logger.warn(
"received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'",
);
}
for (const rawEvent of ev.detail.data.state) {
// Verify the room ID matches, since it's possible for the client to
// send us state updates from other rooms if this widget is always
// on screen
if (rawEvent.room_id === this.roomId) {
const event = new MatrixEvent(rawEvent as Partial<IEvent>);
if (this.syncApi instanceof SyncApi) {
await this.syncApi.injectRoomEvents(this.room!, undefined, [event]);
} else {
// Sliding Sync
await this.syncApi!.injectRoomEvents(this.room!, [event]);
}
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
}
}
await this.ack(ev);
};
private async watchTurnServers(): Promise<void> {
const servers = this.widgetApi.getTurnServers();
const onClientStopped = (): void => {
+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),
);
}
/**
+16 -7
View File
@@ -37,11 +37,17 @@ interface TypedResponse<T> extends Response {
json(): Promise<T>;
}
export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
? string
: O extends { onlyData: true } | undefined
? T
: TypedResponse<T>;
/**
* The type returned by {@link FetchHttpApi.request}, etc.
*
* If {@link IHttpOpts.onlyData} is unset or false, then the request methods return a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object,
* which we abstract via `TypedResponse`. Otherwise, we just cast it to `T`.
*
* @typeParam T - The type (specified by the application on the request method) that we will cast the response to.
* @typeParam O - The type of the options object on the {@link FetchHttpApi} instance.
*/
export type ResponseType<T, O extends IHttpOpts> = O extends { onlyData: true } | undefined ? T : TypedResponse<T>;
export class FetchHttpApi<O extends IHttpOpts> {
private abortController = new AbortController();
@@ -379,9 +385,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;
}
}
+10 -10
View File
@@ -24,7 +24,7 @@ import {
type UploadResponse,
} from "./interface.ts";
import { MediaPrefix } from "./prefix.ts";
import { defer, type QueryDict, removeElement } from "../utils.ts";
import { type QueryDict, removeElement } from "../utils.ts";
import * as callbacks from "../realtime-callbacks.ts";
import { Method } from "./method.ts";
import { ConnectionError } from "./errors.ts";
@@ -65,14 +65,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
total: 0,
abortController,
} as Upload;
const deferred = defer<UploadResponse>();
const uploadResolvers = Promise.withResolvers<UploadResponse>();
if (globalThis.XMLHttpRequest) {
const xhr = new globalThis.XMLHttpRequest();
const timeoutFn = function (): void {
xhr.abort();
deferred.reject(new Error("Timeout"));
uploadResolvers.reject(new Error("Timeout"));
};
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
@@ -91,16 +91,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
}
if (xhr.status >= 400) {
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
uploadResolvers.reject(parseErrorResponse(xhr, xhr.responseText));
} else {
deferred.resolve(JSON.parse(xhr.responseText));
uploadResolvers.resolve(JSON.parse(xhr.responseText));
}
} catch (err) {
if ((<Error>err).name === "AbortError") {
deferred.reject(err);
uploadResolvers.reject(err);
return;
}
deferred.reject(new ConnectionError("request failed", <Error>err));
uploadResolvers.reject(new ConnectionError("request failed", <Error>err));
}
break;
}
@@ -153,16 +153,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
.then((response) => {
return this.opts.onlyData ? <UploadResponse>response : response.json();
})
.then(deferred.resolve, deferred.reject);
.then(uploadResolvers.resolve, uploadResolvers.reject);
}
// remove the upload from the list on completion
upload.promise = deferred.promise.finally(() => {
upload.promise = uploadResolvers.promise.finally(() => {
removeElement(this.uploads, (elem) => elem === upload);
});
abortController.signal.addEventListener("abort", () => {
removeElement(this.uploads, (elem) => elem === upload);
deferred.reject(new DOMException("Aborted", "AbortError"));
uploadResolvers.reject(new DOMException("Aborted", "AbortError"));
});
this.uploads.push(upload);
return upload.promise;
+2 -1
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;
@@ -52,7 +53,7 @@ export interface IHttpOpts {
baseUrl: string;
idBaseUrl?: string;
prefix: string;
extraParams?: Record<string, string>;
extraParams?: QueryDict;
accessToken?: string;
/**
+3 -1
View File
@@ -104,7 +104,9 @@ export class TokenRefresher {
if (snapshot?.expiry) {
// If our token is unknown, but it should not have expired yet, then we should not refresh
const expiresIn = snapshot.expiry.getTime() - Date.now();
if (expiresIn <= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
// If it still has plenty of time left on the clock, we assume something else must be wrong and
// do not refresh. Otherwise if it's expired, or will soon, we try refreshing.
if (expiresIn >= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
return TokenRefreshOutcome.Logout;
}
}
+2 -3
View File
@@ -18,7 +18,6 @@ limitations under the License.
import { logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
import { defer, type IDeferred } from "./utils.ts";
import { MatrixError } from "./http-api/index.ts";
import { type UserIdentifier } from "./@types/auth.ts";
@@ -262,7 +261,7 @@ export class InteractiveAuth<T> {
private data: IAuthData & MatrixError["data"];
private emailSid?: string;
private requestingEmailToken = false;
private attemptAuthDeferred: IDeferred<T> | null = null;
private attemptAuthDeferred: PromiseWithResolvers<T> | null = null;
private chosenFlow: UIAFlow | null = null;
private currentStage: string | null = null;
@@ -298,7 +297,7 @@ export class InteractiveAuth<T> {
public async attemptAuth(): Promise<T> {
// This promise will be quite long-lived and will resolve when the
// request is authenticated and completes successfully.
this.attemptAuthDeferred = defer();
this.attemptAuthDeferred = Promise.withResolvers();
// pluck the promise out now, as doRequest may clear before we return
const promise = this.attemptAuthDeferred.promise;
+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();
+28 -11
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,
@@ -89,7 +90,7 @@ export class EncryptionManager implements IEncryptionManager {
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBufferLike>,
keyBin: Uint8Array,
encryptionKeyIndex: number,
participantId: string,
) => void,
@@ -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]);
+4 -2
View File
@@ -14,14 +14,16 @@ 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",
NotSupportedError = "not_supported_error",
}
export type KeyTransportEventsHandlerMap = {
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
[KeyTransportEvents.NotSupportedError]: () => void;
};
export type KeyTransportEventListener = (
@@ -43,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;
+5 -1
View File
@@ -55,9 +55,13 @@ export interface IMembershipManager {
* Get the actual connection status of the manager.
*/
get status(): Status;
/**
* The current status while the manager is activated
* The Current own state event if the manger is connected.
* `undefined` if not connected.
*/
get ownMembership(): CallMembership | undefined;
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
-412
View File
@@ -1,412 +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 membershipServerSideExpiryTimeout},
* this stores a delay that the server does allow.
*/
private membershipServerSideExpiryTimeoutOverride?: number;
private disconnectDelayId: string | undefined;
private get callMemberEventRetryDelayMinimum(): number {
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
}
private get membershipExpiryTimeout(): number {
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
}
private get membershipServerSideExpiryTimeout(): number {
return (
this.membershipServerSideExpiryTimeoutOverride ??
this.joinConfig?.membershipServerSideExpiryTimeout ??
8_000
);
}
private get membershipKeepAlivePeriod(): number {
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
}
private get callMemberEventRetryJitter(): number {
return this.joinConfig?.callMemberEventRetryJitter ?? 2_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.membershipExpiryTimeout;
// 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.membershipServerSideExpiryTimeout,
},
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.membershipServerSideExpiryTimeout > maxDelayAllowed
) {
this.membershipServerSideExpiryTimeoutOverride = 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.callMemberEventRetryDelayMinimum + Math.random() * this.callMemberEventRetryJitter;
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.membershipKeepAlivePeriod);
}
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;
}
}
}
}
+165 -70
View File
@@ -19,18 +19,18 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { EventTimeline } from "../models/event-timeline.ts";
import { type Room } from "../models/room.ts";
import { type MatrixClient } from "../client.ts";
import { EventType } from "../@types/event.ts";
import { EventType, RelationType } from "../@types/event.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { KnownMembership } from "../@types/membership.ts";
import { MembershipManager } from "./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 { type Statistics, type RTCNotificationType } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
@@ -66,11 +66,27 @@ export type MatrixRTCSessionEventHandlerMap = {
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
};
export interface SessionConfig {
/**
* What kind of notification to send when starting the session.
* @default `undefined` (no notification)
*/
notificationType?: RTCNotificationType;
}
// The 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:
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
export interface MembershipConfig {
/**
* Use the new Manager.
*
* Default: `false`.
* @deprecated does nothing anymore we always default to the new memberhip manager.
*/
useNewMembershipManager?: boolean;
@@ -80,6 +96,8 @@ 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;
/**
@@ -91,36 +109,25 @@ 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 period (in milliseconds) with which we check that our membership event still exists on the
* server. If it is not found we create it again.
*/
memberEventCheckPeriod?: number;
/**
* The minimum delay (in milliseconds) after which we will retry sending the membership event if it
* failed to send.
*/
callMemberEventRetryDelayMinimum?: 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;
/**
* @deprecated It should be possible to make it stable without this.
*/
callMemberEventRetryJitter?: 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.
*/
@@ -131,10 +138,30 @@ export interface MembershipConfig {
*/
maximumNetworkErrorRetryCount?: number;
/**
* The time (in milliseconds) after which we will retry a http request if it
* 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 {
@@ -147,22 +174,38 @@ export interface EncryptionConfig {
/**
* The minimum time (in milliseconds) between each attempt to send encryption key(s).
* e.g. if this is set to 1000, then we will send at most one key event every second.
* @deprecated - Not used by the new encryption manager.
*/
updateEncryptionKeyThrottle?: number;
/**
* Sometimes it is necessary to rotate the encryption key after a membership update.
* For performance reasons we might not want to rotate the key immediately but allow future memberships to use the same key.
* If 5 people join in a row in less than 5 seconds, we don't want to rotate the key for each of them.
* If 5 people leave in a row in less than 5 seconds, we don't want to rotate the key for each of them.
* So we do share the key which was already used live for <5s to new joiners.
* This does result in a potential leak up to the configured time of call media.
* This has to be considered when choosing a value for this property.
*/
keyRotationGracePeriodMs?: number;
/**
* The delay (in milliseconds) after a member leaves before we create and publish a new key, because people
* tend to leave calls at the same time.
* @deprecated - Not used by the new encryption manager.
*/
makeKeyDelay?: number;
/**
* The delay (in milliseconds) between creating and sending a new key and starting to encrypt with it. This
* gives other a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
* The total time between a member leaving and the call switching to new keys is therefore:
* makeKeyDelay + useKeyDelay
* The delay (in milliseconds) between sending a new key and starting to encrypt with it. This
* gives others a chance to receive the new key to minimize the chance they get media they can't decrypt.
*
* The higher this value is, the better it is for existing members as they will have a smoother experience.
* But it impacts new joiners: They will always have to wait `useKeyDelay` before being able to decrypt the media
* (as it will be encrypted with the new key after the delay only), even if the key has already arrived before the delay.
*/
useKeyDelay?: number;
}
export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
/**
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
@@ -176,7 +219,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
private encryptionManager?: IEncryptionManager;
// The session Id of the call, this is the call_id of the call Member event.
private _callId: string | undefined;
private joinConfig?: SessionConfig;
private logger: Logger;
private pendingNotificationToSend: undefined | RTCNotificationType;
/**
* This timeout is responsible to track any expiration. We need to know when we have to start
* to ignore other call members. There is no callback for this. This timeout will always be configured to
@@ -382,22 +428,19 @@ 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.logger,
);
// 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];
@@ -408,22 +451,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<ArrayBufferLike>, 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);
@@ -493,29 +559,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
*/
@@ -540,6 +590,35 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
}
/**
* Sends a notification corresponding to the configured notify type.
*/
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
// Send legacy event:
this.client
.sendEvent(this.roomSubset.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": notificationType === "notification" ? "notify" : notificationType,
"call_id": this.callId!,
})
.catch((e) => this.logger.error("Failed to send call notification", e));
// Send new event:
this.client
.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, {
"m.mentions": { user_ids: [], room: true },
"notification_type": notificationType,
"m.relates_to": {
event_id: parentEventId,
rel_type: RelationType.unstable_RTCNotificationParent,
},
"sender_ts": Date.now(),
"lifetime": 30_000, // 30 seconds
})
.catch((e) => this.logger.error("Failed to send call notification", e));
}
/**
* Call this when the Matrix room members have changed.
*/
@@ -572,12 +651,28 @@ export class MatrixRTCSession extends TypedEventEmitter<
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)
+7 -3
View File
@@ -14,7 +14,7 @@ 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";
@@ -48,8 +48,12 @@ 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,
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
}
@@ -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 { 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
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
SendLeaveEvent = "SendLeaveEvent",
// -> MembershipActionType.SendLeaveEvent
}
@@ -169,7 +174,7 @@ export class MembershipManager
}
this.fociPreferred = fociPreferred;
this.focusActive = focusActive;
this.leavePromiseDefer = undefined;
this.leavePromiseResolvers = undefined;
this.activated = true;
this.oldStatus = this.status;
this.state = MembershipManager.defaultState;
@@ -188,8 +193,8 @@ export class MembershipManager
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
}
if (!this.scheduler.running) {
this.leavePromiseDefer?.resolve(true);
this.leavePromiseDefer = undefined;
this.leavePromiseResolvers?.resolve(true);
this.leavePromiseResolvers = undefined;
}
});
}
@@ -207,21 +212,28 @@ export class MembershipManager
// We use the promise to track if we already scheduled a leave event
// So we do not check scheduler.actions/scheduler.insertions
if (!this.leavePromiseDefer) {
if (!this.leavePromiseResolvers) {
// reset scheduled actions so we will not do any new actions.
this.leavePromiseDefer = defer<boolean>();
this.leavePromiseResolvers = Promise.withResolvers<boolean>();
this.activated = false;
this.scheduler.initiateLeave();
if (timeout) setTimeout(() => this.leavePromiseDefer?.resolve(false), timeout);
if (timeout) setTimeout(() => this.leavePromiseResolvers?.resolve(false), timeout);
}
return this.leavePromiseDefer.promise;
return this.leavePromiseResolvers.promise;
}
private leavePromiseDefer?: 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 +241,15 @@ export class MembershipManager
MembershipActionType.SendJoinEvent,
];
this.logger.warn("Missing own membership: force re-join");
this.state.hasMemberStateEvent = false;
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
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();
}
}
@@ -284,7 +297,7 @@ export class MembershipManager
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 +322,11 @@ export class MembershipManager
}, this.logger);
}
private _ownMembership?: CallMembership;
public get ownMembership(): CallMembership | undefined {
return this._ownMembership;
}
// scheduler
private oldStatus?: Status;
private scheduler: ActionScheduler;
@@ -333,33 +351,38 @@ export class MembershipManager
private focusActive?: Focus;
// Config:
private membershipServerSideExpiryTimeoutOverride?: number;
private delayedLeaveEventDelayMsOverride?: number;
private get callMemberEventRetryDelayMinimum(): number {
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
private get networkErrorRetryMs(): number {
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
}
private get membershipEventExpiryTimeout(): number {
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
}
private get membershipEventExpiryTimeoutHeadroom(): number {
return this.joinConfig?.membershipExpiryTimeoutHeadroom ?? 5_000;
}
private computeNextExpiryActionTs(iteration: number): number {
private get membershipEventExpiryMs(): number {
return (
this.state.startTime +
this.membershipEventExpiryTimeout * iteration -
this.membershipEventExpiryTimeoutHeadroom
this.joinConfig?.membershipEventExpiryMs ??
this.joinConfig?.membershipExpiryTimeout ??
DEFAULT_EXPIRE_DURATION
);
}
private get membershipServerSideExpiryTimeout(): number {
private get membershipEventExpiryHeadroomMs(): number {
return (
this.membershipServerSideExpiryTimeoutOverride ??
this.joinConfig?.membershipEventExpiryHeadroomMs ??
this.joinConfig?.membershipExpiryTimeoutHeadroom ??
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
);
}
private get membershipKeepAlivePeriod(): number {
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
private get delayedLeaveEventRestartMs(): number {
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
}
private get maximumRateLimitRetryCount(): number {
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
@@ -368,6 +391,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) {
@@ -377,13 +403,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: {
@@ -432,7 +458,7 @@ export class MembershipManager
._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.membershipServerSideExpiryTimeout,
delay: this.delayedLeaveEventDelayMs,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
@@ -447,7 +473,7 @@ export class MembershipManager
// due to lack of https://github.com/element-hq/synapse/pull/17810
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
this.delayedLeaveEventRestartMs,
);
} else {
// This action was scheduled because we are in the process of joining
@@ -483,7 +509,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)
@@ -519,13 +545,23 @@ export class MembershipManager
}
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart)
const abortPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new AbortError("Restart delayed event timed out before the HS responded"));
}, this.delayedLeaveEventRestartLocalTimeoutMs);
});
// The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded
// to the widget driver this information would ge lost. That is why we mimic the AbortError using the race.
return await Promise.race([
this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart),
abortPromise,
])
.then(() => {
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
this.delayedLeaveEventRestartMs,
);
})
.catch((e) => {
@@ -579,7 +615,7 @@ export class MembershipManager
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryTimeout),
this.makeMyMembership(this.membershipEventExpiryMs),
this.stateKey,
)
.then(() => {
@@ -611,7 +647,7 @@ export class MembershipManager
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryTimeout * nextExpireUpdateIteration),
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
this.stateKey,
)
.then(() => {
@@ -697,8 +733,8 @@ export class MembershipManager
error.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
) {
const maxDelayAllowed = error.data["org.matrix.msc4140.max_delay"];
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
if (typeof maxDelayAllowed === "number" && this.delayedLeaveEventDelayMs > maxDelayAllowed) {
this.delayedLeaveEventDelayMsOverride = maxDelayAllowed;
}
this.logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
return true;
@@ -769,14 +805,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;
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
// 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")) {
@@ -819,7 +860,7 @@ export class MembershipManager
// retry boundary
if (retries < this.maximumNetworkErrorRetryCount) {
this.state.networkErrorRetries.set(type, retries + 1);
return createInsertActionUpdate(type, this.callMemberEventRetryDelayMinimum);
return createInsertActionUpdate(type, retryDuration);
}
// Failure
@@ -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 {
+374
View File
@@ -0,0 +1,374 @@
/*
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 {
/**
* 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.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 {
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) => {
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;
}
}
+16 -4
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 type { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.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" : "") +
@@ -114,6 +114,18 @@ export class RoomAndToDeviceTransport
(this._enabled.toDevice ? "to device transport" : ""),
);
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
if (this._enabled.toDevice) await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
if (this._enabled.toDevice) {
try {
await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
} catch (error) {
if (error instanceof NotSupportedError && !this._enabled.room) {
this.logger.warn(
"To device is not supported enabling room key transport, disabling toDevice transport",
);
this.setEnabled({ toDevice: false, room: true });
await this.sendKey(keyBase64Encoded, index, members);
}
}
}
}
}
+2 -3
View File
@@ -15,13 +15,12 @@ limitations under the License.
*/
import type { MatrixClient } from "../client.ts";
import type { EncryptionKeysEventContent, Statistics } from "./types.ts";
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { EventType } from "../@types/event.ts";
import { type MatrixError } from "../http-api/errors.ts";
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type CallMembership } from "./CallMembership.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts";
@@ -81,7 +80,7 @@ export class RoomKeyTransport
}
/** implements {@link IKeyTransport#sendKey} */
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
// members not used in room transports as the keys are sent to all room members
const content: EncryptionKeysEventContent = {
keys: [
+33 -15
View File
@@ -14,15 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type WidgetApiResponseError } from "matrix-widget-api";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import type { CallMembership } from "./CallMembership.ts";
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { ClientEvent, type MatrixClient } from "../client.ts";
import type { MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
export class NotSupportedError extends Error {
public constructor(message?: string) {
super(message);
}
public get name(): string {
return "NotSupportedError";
}
}
/**
* ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the
* to-device CS-API.
@@ -32,6 +41,7 @@ export class ToDeviceKeyTransport
implements IKeyTransport
{
private logger: Logger = rootLogger;
public setParentLogger(parentLogger: Logger): void {
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
}
@@ -56,7 +66,7 @@ export class ToDeviceKeyTransport
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
const content: EncryptionKeysToDeviceEventContent = {
keys: {
index: index,
@@ -71,27 +81,35 @@ export class ToDeviceKeyTransport
application: "m.call",
scope: "m.room",
},
sent_ts: Date.now(),
};
const targets = members
.filter((member) => {
// filter malformed call members
if (member.sender == undefined || member.deviceId == undefined) {
this.logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
return false;
}
// Filter out me
return !(member.sender == this.userId && member.deviceId == this.deviceId);
})
.map((member) => {
return {
userId: member.sender!,
userId: member.userId!,
deviceId: member.deviceId!,
};
});
})
// filter out me
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
if (targets.length > 0) {
await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content);
await this.client
.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content)
.catch((error: WidgetApiResponseError) => {
const msg: string = error.message;
// This is not ideal. We would want to have a custom error type for unsupported actions.
// This is not part of the widget API spec. Since as of now there are only two implementations:
// Rust SDK + JS-SDK, and the JS-SDK does support to-device sending, we can assume that
// this is a widget driver issue error message.
if (
(msg.includes("unknown variant") && msg.includes("send_to_device")) ||
msg.includes("not supported")
) {
throw new NotSupportedError("The widget driver does not support to-device encryption");
}
});
this.statistics.counters.roomEventEncryptionKeysSent += 1;
} else {
this.logger.warn("No targets found for sending key");

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