Compare commits

...

102 Commits

Author SHA1 Message Date
RiotRobot 946cf4f359 v37.11.0 2025-07-15 14:44:53 +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
RiotRobot 663136a95a v37.5.0-rc.0 2025-04-29 10:42:21 +00:00
Michael Telatynski d67b19fa88 Refactor how token refreshing works to be more resilient (#4819)
* Refactor how token refreshing works to be more resilient

1. ensure we do use the new token if it is not explicitly inhibited by the caller
2. eagerly refresh token if we know it is expired
3. allow refreshing a token multiple times if e.g. on bad connection or the environment has been slept and sufficient time has passed since the last refresh attempt

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

* Iterate

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

* Iterate

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

* Add exponential backoff

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

* Ensure no timing effects on `authedRequest` method call

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

* Iterate

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-29 08:13:27 +00:00
Johannes Marbach 6ec200adcf Stabilise MSC3765 (#4767)
* Stabilise MSC3765

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Remove unstable content and hardcode property name

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2025-04-25 12:35:18 +00:00
Timo 19b1b901f5 Inherit methodFactory extensions from the parent to the child loggers. (#4809)
* use methodFactory extensions from the rootLogger in child loggers.

* use simple method factory copy AND `childLogger.setLevel(childLogger.getLevel());`
This is the important part that actually registers the new methods.

* add comments and  find a way to make it clearer that the types are correct.

* review

* additionally fix MatrixRTCSessionManager being initialized before the extension is in place.

* Add comment to clarify order of log extensions and creating childs.

* review

* Set "loglevel" min version to guarantee access to `logger.rebuild`
2025-04-22 16:27:18 +00:00
RiotRobot 1f52fa0c43 Merge branch 'master' into develop 2025-04-22 12:52:54 +00:00
RiotRobot b08c083c46 v37.4.0 2025-04-22 12:52:26 +00:00
Richard van der Hoff 69f1bea89b Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.1.0 (#4811)
* Convert import/export room key tests to snapshots

* Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.1.0

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 13:51:38 +00:00
renovate[bot] a52c64b747 Update typescript-eslint monorepo to v8.29.1 (#4806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:46:11 +00:00
renovate[bot] de9eb0e7f2 Update all non-major dependencies (#4802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:33:53 +00:00
renovate[bot] a97d7f1133 Update guibranco/github-status-action-v2 digest to 5f2b01c (#4801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:19:39 +00:00
renovate[bot] bc399abf6e Update dependency typescript to v5.8.3 (#4805)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:14:39 +00:00
renovate[bot] 1085a2469a Update dependency typedoc to v0.28.2 (#4804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:14:02 +00:00
renovate[bot] 52a9c52b12 Update dependency @types/node to v18.19.86 (#4803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:13:28 +00:00
117 changed files with 5026 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@9b1d102b3c32583174557f58c53e3b09d43d1b1d
- 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@9b1d102b3c32583174557f58c53e3b09d43d1b1d
- 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@9b1d102b3c32583174557f58c53e3b09d43d1b1d
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
+94
View File
@@ -1,3 +1,97 @@
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
* MatrixRTC: Add combined `toDeviceAndRoomKeyTransport` ([#4792](https://github.com/matrix-org/matrix-js-sdk/pull/4792)). Contributed by @toger5.
* Make logging consistent for matrixRTC ([#4788](https://github.com/matrix-org/matrix-js-sdk/pull/4788)). Contributed by @toger5.
* MatrixRTC: ToDevice distribution for media stream keys ([#4785](https://github.com/matrix-org/matrix-js-sdk/pull/4785)). Contributed by @BillCarsonFr.
## 🐛 Bug Fixes
* Fix token refresh racing with other requests and not using new token ([#4798](https://github.com/matrix-org/matrix-js-sdk/pull/4798)). Contributed by @t3chguy.
* Fix fallback to MemoryCryptoStore when LocalStorage unavailable ([#4797](https://github.com/matrix-org/matrix-js-sdk/pull/4797)). Contributed by @t3chguy.
* Remove duplicate `deleteSecretStorage` in `RustCrypto.resetEncryption` ([#4789](https://github.com/matrix-org/matrix-js-sdk/pull/4789)). Contributed by @florianduros.
* Fix `RustCrypto.resetEncryption` failure ([#4772](https://github.com/matrix-org/matrix-js-sdk/pull/4772)). Contributed by @florianduros.
Changes in [37.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.3.0) (2025-04-08)
==================================================================================================
## ✨ 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
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
+9 -11
View File
@@ -1,16 +1,15 @@
{
"name": "matrix-js-sdk",
"version": "37.4.0-rc.0",
"version": "37.11.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,13 +49,12 @@
],
"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",
"jwt-decode": "^4.0.0",
"loglevel": "^1.7.1",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.10.0",
"oidc-client-ts": "^3.0.1",
@@ -81,6 +79,7 @@
"@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",
"@types/content-type": "^1.1.5",
@@ -88,7 +87,6 @@
"@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",
"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";
+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
+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");
});
});
});
+18 -19
View File
@@ -44,7 +44,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 +84,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 +110,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 +253,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 +347,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");
@@ -1540,10 +1539,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 +1554,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 +1569,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 +1603,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 +1637,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: {
+21
View File
@@ -1796,6 +1796,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
}
-3
View File
@@ -1,6 +1,3 @@
// load olm before the sdk if possible
import "../olm-loader";
// eslint-disable-next-line no-restricted-imports
import type EventEmitter from "events";
import { logger } from "../../src/logger";
+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;
});
});
+4 -4
View File
@@ -196,14 +196,14 @@ describe("Topic content helpers", () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
});
});
+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"] });
});
});
});
+147 -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", () => {
@@ -356,7 +356,9 @@ describe("FetchHttpApi", () => {
accessToken,
refreshToken,
});
const result = await api.authedRequest(Method.Post, "/account/password");
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
headers: {},
});
expect(result).toEqual(okayResponse);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
@@ -366,12 +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,
// 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
@@ -391,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);
@@ -402,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);
});
});
});
});
@@ -461,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>;
@@ -478,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");
@@ -497,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,
@@ -552,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,
+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();
});
+31 -144
View File
@@ -16,11 +16,12 @@ 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 { 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 { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
const mockFocus = { type: "mock" };
@@ -201,58 +202,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 +269,10 @@ describe("MatrixRTCSession", () => {
describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock;
let sendEventMock: 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();
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
client.sendEvent = sendEventMock;
client._unstable_updateDelayedEvent = jest.fn();
@@ -367,67 +298,6 @@ describe("MatrixRTCSession", () => {
sess!.joinRoomSession([mockFocus], mockFocus);
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.
jest.useRealTimers();
});
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();
});
});
describe("onMembershipsChanged", () => {
@@ -489,9 +359,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 +439,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);
@@ -878,11 +746,27 @@ describe("MatrixRTCSession", () => {
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;
@@ -993,13 +877,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();
}
+125 -95
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 = {
@@ -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);
@@ -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];
@@ -460,7 +492,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 +512,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 +539,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 +562,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 +591,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 +608,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 +625,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 +635,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 +652,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 +680,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 +690,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 +711,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 +723,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 +731,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 +743,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 +751,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 +780,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);
@@ -778,5 +808,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,598 @@
/*
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 type { Logger } from "../../../src/logger.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,
);
});
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 () => {
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.runOnlyPendingTimersAsync();
// 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(members);
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", 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.runOnlyPendingTimersAsync();
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();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(updatedMembers);
encryptionManager.onMembershipsUpdate(updatedMembers);
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);
});
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.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(200);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(100);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(50);
encryptionManager.onMembershipsUpdate(members);
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(updatedMembers);
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 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(members);
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(members);
await jest.advanceTimersByTimeAsync(10);
// And another one ( plus a joiner)
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
getMembershipMock.mockReturnValue(lastMembership);
// The key `1` rollout is still in progress
encryptionManager.onMembershipsUpdate(lastMembership);
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 beed 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(
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
"!room:id",
userId,
);
}
});
@@ -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;
+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);
});
});
});
+30 -20
View File
@@ -130,10 +130,12 @@ describe("OidcTokenRefresher", () => {
method: "POST",
});
expect(result).toEqual({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
});
expect(result).toEqual(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should persist the new tokens", async () => {
@@ -144,10 +146,12 @@ describe("OidcTokenRefresher", () => {
await refresher.doRefreshAccessToken("refresh-token");
expect(refresher.persistTokens).toHaveBeenCalledWith({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
});
expect(refresher.persistTokens).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should only have one inflight refresh request at once", async () => {
@@ -189,10 +193,12 @@ describe("OidcTokenRefresher", () => {
// only one call to token endpoint
expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
expect(result1).toEqual({
accessToken: "first-new-access-token",
refreshToken: "first-new-refresh-token",
});
expect(result1).toEqual(
expect.objectContaining({
accessToken: "first-new-access-token",
refreshToken: "first-new-refresh-token",
}),
);
// same response
expect(result1).toEqual(result2);
@@ -200,10 +206,12 @@ describe("OidcTokenRefresher", () => {
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
// called token endpoint, got new tokens
expect(third).toEqual({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
});
expect(third).toEqual(
expect.objectContaining({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
}),
);
});
it("should log and rethrow when token refresh fails", async () => {
@@ -261,10 +269,12 @@ describe("OidcTokenRefresher", () => {
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
// called token endpoint, got new tokens
expect(result).toEqual({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
});
expect(result).toEqual(
expect.objectContaining({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
}),
);
});
it("should throw TokenRefreshLogoutError when expired", async () => {
+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([]);
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
},
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
}
`;
exports[`RustCrypto importing and exporting room keys should import and export keys as JSON 1`] = `
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
},
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
}
`;
+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);
});
+10 -17
View File
@@ -68,7 +68,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";
@@ -514,11 +513,8 @@ describe("RustCrypto", () => {
expect(Array.isArray(keys)).toBeTruthy();
expect(keys.length).toBe(someRoomKeys.length);
const aSession = someRoomKeys[0];
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
expect(aSession).toStrictEqual(exportedKey);
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
expect(exportedKey).toMatchSnapshot();
});
it("should import and export keys as JSON", async () => {
@@ -539,11 +535,8 @@ describe("RustCrypto", () => {
expect(Array.isArray(keys)).toBeTruthy();
expect(keys.length).toBe(someRoomKeys.length);
const aSession = someRoomKeys[0];
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
expect(aSession).toStrictEqual(exportedKey);
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
expect(exportedKey).toMatchSnapshot();
});
});
@@ -558,11 +551,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[] = [
@@ -583,7 +576,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 () => {
@@ -934,8 +927,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", "{}");
@@ -952,7 +945,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);
});
+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 () => {
+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 {
+3 -6
View File
@@ -13,7 +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 { UnstableValue } from "../NamespacedValue.ts";
import { NamespacedValue } from "../NamespacedValue.ts";
import { type IMessageRendering } from "./extensible_events.ts";
/**
@@ -42,20 +42,17 @@ import { type IMessageRendering } from "./extensible_events.ts";
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
export const M_TOPIC = new NamespacedValue("m.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = IMessageRendering[];
type MTopicStable = { [M_TOPIC.altName]: MTopicContent };
type MTopicUnstable = { [M_TOPIC.name]: MTopicContent };
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = (MTopicStable & MTopicUnstable) | MTopicStable | MTopicUnstable;
export type MTopicEvent = { "m.topic": MTopicContent };
/**
* The event content for an m.room.topic event
+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,
+171 -138
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 {
@@ -360,7 +365,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}.
@@ -883,76 +888,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 +970,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 +998,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 +1013,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 +1024,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 +1095,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 +1365,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 +1389,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 +1506,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
return this.canResetTimelineCallback(roomId);
},
logger: this.logger.getChild("sync"),
};
}
@@ -1527,9 +1552,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 +1582,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 +1931,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 +1946,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async initRustCrypto(
args: {
useIndexedDB?: boolean;
cryptoDatabasePrefix?: string;
storageKey?: Uint8Array;
storagePassword?: string;
} = {},
@@ -1950,7 +1983,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,
@@ -2191,7 +2224,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 +2236,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);
@@ -5173,24 +5206,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 +7328,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.
+5 -3
View File
@@ -189,12 +189,14 @@ export type MakeTopicContent = (topic: string | null | undefined, htmlTopic?: st
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [];
if (isProvided(topic)) {
renderings.push({ body: topic, mimetype: "text/plain" });
}
// Put HTML first because clients will render the first type in
// the array that they understand
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
}
if (isProvided(topic)) {
renderings.push({ body: topic, mimetype: "text/plain" });
}
return { topic, [M_TOPIC.name]: renderings };
};
+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),
);
}
/**
+40 -78
View File
@@ -18,10 +18,10 @@ limitations under the License.
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
import { checkObjectHasKeys, deepCopy, encodeParams } from "../utils.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { Method } from "./method.ts";
import { ConnectionError, MatrixError, TokenRefreshError, TokenRefreshLogoutError } from "./errors.ts";
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
import {
HttpApiEvent,
type HttpApiEventHandlerMap,
@@ -31,7 +31,7 @@ import {
} from "./interface.ts";
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
import { type QueryDict } from "../utils.ts";
import { singleAsyncExecution } from "../utils/decorators.ts";
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
interface TypedResponse<T> extends Response {
json(): Promise<T>;
@@ -43,14 +43,9 @@ export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
? T
: TypedResponse<T>;
const enum TokenRefreshOutcome {
Success = "success",
Failure = "failure",
Logout = "logout",
}
export class FetchHttpApi<O extends IHttpOpts> {
private abortController = new AbortController();
private readonly tokenRefresher: TokenRefresher;
public constructor(
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
@@ -59,6 +54,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
opts.onlyData = !!opts.onlyData;
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
this.tokenRefresher = new TokenRefresher(opts);
}
public abort(): void {
@@ -113,12 +110,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
return this.requestOtherUrl(method, fullUri, body, opts);
}
/**
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
* @private
*/
private tokenRefreshPromise?: Promise<unknown>;
/**
* Perform an authorised request to the homeserver.
* @param method - The HTTP method e.g. "GET".
@@ -146,36 +137,45 @@ export class FetchHttpApi<O extends IHttpOpts> {
* @returns Rejects with an error if a problem occurred.
* This includes network problems and Matrix-specific error JSON.
*/
public async authedRequest<T>(
public authedRequest<T>(
method: Method,
path: string,
queryParams?: QueryDict,
queryParams: QueryDict = {},
body?: Body,
paramOpts: IRequestOpts & { doNotAttemptTokenRefresh?: boolean } = {},
paramOpts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
if (!queryParams) queryParams = {};
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
}
// Wrapper around public method authedRequest to allow for tracking retry attempt counts
private async doAuthedRequest<T>(
attempt: number,
method: Method,
path: string,
queryParams: QueryDict,
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
// avoid mutating paramOpts so they can be used on retry
const opts = { ...paramOpts };
const opts = deepCopy(paramOpts);
// we have to manually copy the abortSignal over as it is not a plain object
opts.abortSignal = paramOpts.abortSignal;
// Await any ongoing token refresh before we build the headers/params
await this.tokenRefreshPromise;
// Take a copy of the access token so we have a record of the token we used for this request if it fails
const accessToken = this.opts.accessToken;
if (accessToken) {
// Take a snapshot of the current token state before we start the request so we can reference it if we error
const requestSnapshot = await this.tokenRefresher.prepareForRequest();
if (requestSnapshot.accessToken) {
if (this.opts.useAuthorizationHeader) {
if (!opts.headers) {
opts.headers = {};
}
if (!opts.headers.Authorization) {
opts.headers.Authorization = `Bearer ${accessToken}`;
opts.headers.Authorization = `Bearer ${requestSnapshot.accessToken}`;
}
if (queryParams.access_token) {
delete queryParams.access_token;
}
} else if (!queryParams.access_token) {
queryParams.access_token = accessToken;
queryParams.access_token = requestSnapshot.accessToken;
}
}
@@ -187,33 +187,19 @@ export class FetchHttpApi<O extends IHttpOpts> {
throw error;
}
if (error.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
// If the access token has changed since we started the request, but before we refreshed it,
// then it was refreshed due to another request failing, so retry before refreshing again.
let outcome: TokenRefreshOutcome | null = null;
if (accessToken === this.opts.accessToken) {
const tokenRefreshPromise = this.tryRefreshToken();
this.tokenRefreshPromise = tokenRefreshPromise;
outcome = await tokenRefreshPromise;
}
if (outcome === TokenRefreshOutcome.Success || outcome === null) {
if (error.errcode === "M_UNKNOWN_TOKEN") {
const outcome = await this.tokenRefresher.handleUnknownToken(requestSnapshot, attempt);
if (outcome === TokenRefreshOutcome.Success) {
// if we got a new token retry the request
return this.authedRequest(method, path, queryParams, body, {
...paramOpts,
// Only attempt token refresh once for each failed request
doNotAttemptTokenRefresh: outcome !== null,
});
return this.doAuthedRequest(attempt + 1, method, path, queryParams, body, paramOpts);
}
if (outcome === TokenRefreshOutcome.Failure) {
throw new TokenRefreshError(error);
}
// Fall through to SessionLoggedOut handler below
}
// otherwise continue with error handling
if (error.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
if (!opts?.inhibitLogoutEmit) {
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
}
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
}
@@ -222,33 +208,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
}
}
/**
* Attempt to refresh access tokens.
* On success, sets new access and refresh tokens in opts.
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
*/
@singleAsyncExecution
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
return TokenRefreshOutcome.Logout;
}
try {
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
this.opts.accessToken = accessToken;
this.opts.refreshToken = refreshToken;
// successfully got new tokens
return TokenRefreshOutcome.Success;
} catch (error) {
this.opts.logger?.warn("Failed to refresh token", error);
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
return TokenRefreshOutcome.Logout;
}
return TokenRefreshOutcome.Failure;
}
}
/**
* Perform a request to the homeserver without any credentials.
* @param method - The HTTP method e.g. "GET".
@@ -420,9 +379,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;
+13 -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;
@@ -24,9 +25,20 @@ export type Body = Record<string, any> | BodyInit;
* Unencrypted access and (optional) refresh token
*/
export type AccessTokens = {
/**
* The new access token to use for authenticated requests
*/
accessToken: string;
/**
* The new refresh token to use for refreshing tokens, optional
*/
refreshToken?: string;
/**
* Approximate date when the access token will expire, optional
*/
expiry?: Date;
};
/**
* @experimental
* Function that performs token refresh using the given refreshToken.
@@ -41,7 +53,7 @@ export interface IHttpOpts {
baseUrl: string;
idBaseUrl?: string;
prefix: string;
extraParams?: Record<string, string>;
extraParams?: QueryDict;
accessToken?: string;
/**
+167
View File
@@ -0,0 +1,167 @@
/*
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 { MatrixError, TokenRefreshLogoutError } from "./errors.ts";
import { type IHttpOpts } from "./interface.ts";
import { sleep } from "../utils.ts";
/**
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
export const enum TokenRefreshOutcome {
Success = "success",
Failure = "failure",
Logout = "logout",
}
interface Snapshot {
accessToken: string;
refreshToken?: string;
expiry?: Date;
}
// If the token expires in less than this time amount of time, we will eagerly refresh it before making the intended request.
const REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS = 500;
// If we get an unknown token error and the token expires in less than this time amount of time, we will refresh it before making the intended request.
// Otherwise, we will error as the token should not have expired yet and we need to avoid retrying indefinitely.
const REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS = 60 * 1000;
type Opts = Pick<IHttpOpts, "tokenRefreshFunction" | "logger" | "refreshToken" | "accessToken">;
/**
* This class is responsible for managing the access token and refresh token for authenticated requests.
* It will automatically refresh the access token when it is about to expire, and will handle unknown token errors.
*/
export class TokenRefresher {
public constructor(private readonly opts: Opts) {}
/**
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
* @private
*/
private tokenRefreshPromise?: Promise<TokenRefreshOutcome>;
private latestTokenRefreshExpiry?: Date;
/**
* This function is called before every request to ensure that the access token is valid.
* @returns a snapshot containing the access token and other properties which must be passed to the handleUnknownToken
* handler if an M_UNKNOWN_TOKEN error is encountered.
*/
public async prepareForRequest(): Promise<Snapshot> {
// Ensure our token is refreshed before we build the headers/params
await this.refreshIfNeeded();
return {
accessToken: this.opts.accessToken!,
refreshToken: this.opts.refreshToken,
expiry: this.latestTokenRefreshExpiry,
};
}
private async refreshIfNeeded(): Promise<unknown> {
if (this.tokenRefreshPromise) {
return this.tokenRefreshPromise;
}
// If we don't know the token expiry, we can't eagerly refresh
if (!this.latestTokenRefreshExpiry) return;
const expiresIn = this.latestTokenRefreshExpiry.getTime() - Date.now();
if (expiresIn <= REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS) {
await this._handleUnknownToken();
}
}
/**
* This function is called when an M_UNKNOWN_TOKEN error is encountered.
* It will attempt to refresh the access token if it is unknown, and will return a TokenRefreshOutcome.
* @param snapshot - the snapshot returned by prepareForRequest
* @param attempt - the number of attempts made for this request so far
* @returns a TokenRefreshOutcome indicating the result of the refresh attempt
*/
public async handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome> {
return this._handleUnknownToken(snapshot, attempt);
}
/* eslint-disable @typescript-eslint/naming-convention */
private async _handleUnknownToken(): Promise<TokenRefreshOutcome>;
private async _handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome>;
private async _handleUnknownToken(snapshot?: Snapshot, attempt?: number): Promise<TokenRefreshOutcome> {
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 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;
}
}
if (!snapshot || snapshot?.accessToken === this.opts.accessToken) {
// If we have a snapshot, but the access token is the same as the current one then a refresh
// did not happen behind us but one may be ongoing anyway
this.tokenRefreshPromise ??= this.doTokenRefresh(attempt);
try {
return await this.tokenRefreshPromise;
} finally {
this.tokenRefreshPromise = undefined;
}
}
// We may end up here if the token was refreshed in the background due to another request
return TokenRefreshOutcome.Success;
}
/**
* Attempt to refresh access tokens.
* On success, sets new access and refresh tokens in opts.
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
*/
private async doTokenRefresh(attempt?: number): Promise<TokenRefreshOutcome> {
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
this.opts.logger?.error("Unable to refresh token - no refresh token or refresh function");
return TokenRefreshOutcome.Logout;
}
if (attempt && attempt > 1) {
// Exponential backoff to ensure we don't trash the server, up to 2^5 seconds
await sleep(1000 * Math.min(32, 2 ** attempt));
}
try {
this.opts.logger?.debug("Attempting to refresh token");
const { accessToken, refreshToken, expiry } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
this.opts.accessToken = accessToken;
this.opts.refreshToken = refreshToken;
this.latestTokenRefreshExpiry = expiry;
this.opts.logger?.debug("... token refresh complete, new token expiry:", expiry);
// successfully got new tokens
return TokenRefreshOutcome.Success;
} catch (error) {
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
this.opts.logger?.error("Failed to refresh token", error);
return TokenRefreshOutcome.Logout;
}
this.opts.logger?.warn("Failed to refresh token", error);
return TokenRefreshOutcome.Failure;
}
}
}
+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;
+21 -3
View File
@@ -34,6 +34,11 @@ export interface Logger extends BaseLogger {
/**
* Create a child logger.
*
* This child will use the `methodFactory` of the parent, so any log extensions applied to the parent
* at the time of calling `getChild` will be applied to the child as well.
* It will NOT apply changes to the parent's `methodFactory` after the child was created.
* Those changes need to be applied to the child manually.
*
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
* use this as a prefix; others use a different mechanism.
*/
@@ -128,14 +133,24 @@ interface PrefixedLogger extends loglevel.Logger, LoggerWithLogMethod {
*
* @param prefix Prefix to add to each logged line. If undefined, no prefix will be added.
*/
function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
function getPrefixedLogger(prefix?: string): PrefixedLogger {
const loggerName = DEFAULT_NAMESPACE + (prefix === undefined ? "" : `-${prefix}`);
const prefixLogger = loglevel.getLogger(loggerName) as PrefixedLogger;
if (prefixLogger.getChild === undefined) {
// This is a new loglevel Logger which has not been turned into a PrefixedLogger yet.
prefixLogger.prefix = prefix;
prefixLogger.getChild = (childPrefix): Logger => getPrefixedLogger((prefix ?? "") + childPrefix);
prefixLogger.getChild = (childPrefix): Logger => {
// create the new child logger
const childLogger = getPrefixedLogger((prefix ?? "") + childPrefix);
// Assign the methodFactory from the parent logger.
// This is useful if we add extensions to the parent logger that modifies
// its methodFactory. (An example extension is: storing each log to a rageshake db)
childLogger.methodFactory = prefixLogger.methodFactory;
// Rebuild the child logger with the new methodFactory.
childLogger.rebuild();
return childLogger;
};
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
}
@@ -145,8 +160,11 @@ function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
/**
* 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();
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
/**
* A "span" for grouping related log lines together.
+19 -4
View File
@@ -6,6 +6,7 @@ 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 { getParticipantId } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
@@ -42,6 +43,10 @@ export interface IEncryptionManager {
*
* @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps.
* @deprecated This method is used internally for testing. It is also used to re-emit keys when there is a change
* of RTCSession (matrixKeyProvider#setRTCSession) -Not clear why/when switch RTCSession would occur-. Note that if we switch focus, we do keep the same RTC session,
* so no need to re-emit. But it requires the encryption manager to store all keys of all participants, and this is already done
* by the key provider. We don't want to add another layer of key storage.
*/
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
}
@@ -82,6 +87,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 +95,7 @@ export class EncryptionManager implements IEncryptionManager {
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBufferLike>,
keyBin: Uint8Array,
encryptionKeyIndex: number,
participantId: string,
) => void,
@@ -280,7 +286,18 @@ 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,
@@ -408,8 +425,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;
-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;
}
}
}
}
+70 -45
View File
@@ -24,13 +24,13 @@ 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 { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
@@ -65,12 +65,19 @@ export type MatrixRTCSessionEventHandlerMap = {
) => void;
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
};
// 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 +87,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 +100,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,6 +129,14 @@ 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.
*/
@@ -382,22 +388,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,20 +411,40 @@ 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,
);
}
// Join!
@@ -572,7 +595,9 @@ 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);
});
+11 -7
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger as rootLogger } 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";
@@ -23,8 +23,6 @@ import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
const logger = rootLogger.getChild("[MatrixRTCSessionManager]");
export enum MatrixRTCSessionManagerEvents {
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
SessionStarted = "session_started",
@@ -50,8 +48,14 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// longer the correct session object for the room.
private roomSessions = new Map<string, MatrixRTCSession>();
public constructor(private client: MatrixClient) {
private readonly logger: Logger;
public constructor(
rootLogger: Logger,
private client: MatrixClient,
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
}
public start(): void {
@@ -105,7 +109,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
const room = this.client.getRoom(event.getRoomId());
if (!room) {
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return;
}
@@ -129,10 +133,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
const nowActive = session.memberships.length > 0;
if (wasActiveAndKnown && !nowActive) {
logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
this.logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
} else if (!wasActiveAndKnown && nowActive) {
logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
this.logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
}
}
@@ -21,13 +21,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,
@@ -169,7 +168,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 +187,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,16 +206,17 @@ 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();
@@ -229,14 +229,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 +285,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");
@@ -333,33 +334,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;
@@ -377,13 +383,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 +438,7 @@ export class MembershipManager
._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.membershipServerSideExpiryTimeout,
delay: this.delayedLeaveEventDelayMs,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
@@ -447,7 +453,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 +489,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)
@@ -525,7 +531,7 @@ export class MembershipManager
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
this.delayedLeaveEventRestartMs,
);
})
.catch((e) => {
@@ -579,7 +585,7 @@ export class MembershipManager
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryTimeout),
this.makeMyMembership(this.membershipEventExpiryMs),
this.stateKey,
)
.then(() => {
@@ -611,7 +617,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 +703,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,7 +775,7 @@ 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";
const retryDurationString = this.networkErrorRetryMs / 1000 + "s";
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
if (error instanceof Error && error.name === "AbortError") {
this.logger.warn(
@@ -819,7 +825,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, this.networkErrorRetryMs);
}
// 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 {
+329
View File
@@ -0,0 +1,329 @@
/*
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 {
// 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 delayRolloutTimeMillis = 1000;
/**
* 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(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
// This is deprecated should be ignored. Only used by tests?
return new Map();
}
public join(joinConfig: EncryptionConfig | undefined): void {
this.logger?.info(`Joining room`);
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
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();
}
// 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.onEncryptionKeysChanged(
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
*/
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.onEncryptionKeysChanged(
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: OutboundEncryptionSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: this.nextKeyIndex(),
};
hasKeyChanged = true;
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
// Set this new key as the current one
this.outboundSession = newOutboundKey;
// Send
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
} else if (anyJoined.length > 0) {
// keep the same key
// XXX In the future we want to distribute a ratcheted key not the current one
toDistributeTo = anyJoined;
outboundKey = this.outboundSession!;
} 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.delayRolloutTimeMillis);
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
this.onEncryptionKeysChanged(
outboundKey.key,
outboundKey.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
} catch (err) {
this.logger?.error(`Failed to rollout key`, err);
}
}
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");
+37 -2
View File
@@ -16,11 +16,44 @@ limitations under the License.
import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts";
export type ParticipantId = string;
export interface EncryptionKeyEntry {
index: number;
key: string;
}
/**
* The mxID, deviceId and membership timestamp of a RTC session participant.
*/
export type ParticipantDeviceInfo = {
userId: string;
deviceId: string;
membershipTs: number;
};
/**
* A type representing the information needed to decrypt video streams.
*/
export type InboundEncryptionSession = {
key: Uint8Array;
participantId: ParticipantId;
keyIndex: number;
creationTS: number;
};
/**
* The information about the key used to encrypt video streams.
*/
export type OutboundEncryptionSession = {
key: Uint8Array;
creationTS: number;
// The devices that this key is shared with.
sharedWith: Array<ParticipantDeviceInfo>;
// This is an index acting as the id of the key
keyId: number;
};
export interface EncryptionKeysEventContent {
keys: EncryptionKeyEntry[];
device_id: string;
@@ -28,13 +61,15 @@ export interface EncryptionKeysEventContent {
sent_ts?: number;
}
/**
* THe content of a to-device event that contains encryption keys.
*/
export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string };
member: {
// id: ParticipantId,
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
// Or add some validation on it based on the encryption info
claimed_device_id: string;
// user_id: string
};
room_id: string;
session: {
+51
View File
@@ -0,0 +1,51 @@
/*
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 { InboundEncryptionSession, ParticipantId } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
*/
export class OutdatedKeyFilter {
// Map of participantId -> keyIndex -> timestamp
private tsBuffer: Map<ParticipantId, Map<number, number>> = new Map();
public constructor() {}
/**
* Check if there is a recent key with the same keyId (index) and then use the creationTS to decide what to
* do with the key. If the key received is older than the one already in the buffer, it is ignored.
* @param participantId
* @param item
*/
public isOutdated(participantId: ParticipantId, item: InboundEncryptionSession): boolean {
if (!this.tsBuffer.has(participantId)) {
this.tsBuffer.set(participantId, new Map<number, number>());
}
const latestTimestamp = this.tsBuffer.get(participantId)?.get(item.keyIndex);
if (latestTimestamp && latestTimestamp > item.creationTS) {
// The existing key is more recent, ignore this one
return true;
}
this.tsBuffer.get(participantId)!.set(item.keyIndex, item.creationTS);
return false;
}
}
export function getParticipantId(userId: string, deviceId: string): ParticipantId {
return `${userId}:${deviceId}`;
}
+99 -50
View File
@@ -17,15 +17,15 @@ limitations under the License.
import { M_POLL_START, type Optional } from "matrix-events-sdk";
import {
EventTimelineSet,
DuplicateStrategy,
type IAddLiveEventOptions,
EventTimelineSet,
type EventTimelineSetHandlerMap,
type IAddLiveEventOptions,
} from "./event-timeline-set.ts";
import { Direction, EventTimeline } from "./event-timeline.ts";
import { getHttpUriForMxc } from "../content-repo.ts";
import { removeElement } from "../utils.ts";
import { normalize, noUnsafeEventProps } from "../utils.ts";
import * as utils from "../utils.ts";
import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts";
import {
type IEvent,
type IThreadBundledRelationship,
@@ -35,17 +35,17 @@ import {
} from "./event.ts";
import { EventStatus } from "./event-status.ts";
import { RoomMember } from "./room-member.ts";
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
import { type Hero, type IRoomSummary, RoomSummary } from "./room-summary.ts";
import { logger } from "../logger.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
EventType,
RelationType,
RoomCreateTypeField,
RoomType,
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
} from "../@types/event.ts";
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts";
@@ -53,12 +53,12 @@ import { Filter, type IFilterDefinition } from "../filter.ts";
import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts";
import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts";
import {
FILTER_RELATED_BY_REL_TYPES,
FILTER_RELATED_BY_SENDERS,
Thread,
THREAD_RELATION_TYPE,
ThreadEvent,
type ThreadEventHandlerMap as ThreadHandlerMap,
FILTER_RELATED_BY_REL_TYPES,
THREAD_RELATION_TYPE,
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread.ts";
import {
@@ -74,7 +74,6 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts";
import { isPollEvent, Poll, PollEvent } from "./poll.ts";
import { RoomReceipts } from "./room-receipts.ts";
import { compareEventOrdering } from "./compare-event-ordering.ts";
import * as utils from "../utils.ts";
import { KnownMembership, type Membership } from "../@types/membership.ts";
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
import { type MSC4186Hero } from "../sliding-sync.ts";
@@ -2469,7 +2468,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Adds events to a thread's timeline. Will fire "Thread.update"
*/
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(this.applyRedaction);
events.forEach(this.tryApplyRedaction);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
for (const event of events) {
@@ -2580,55 +2579,105 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return thread;
}
private applyRedaction = (event: MatrixEvent): void => {
/**
* Applies an event as a redaction of another event, regardless of whether the redacting
* event is actually a redaction.
*
* Callers should use tryApplyRedaction instead.
*
* @param redactionEvent The event which redacts an event.
* @param redactedEvent The event being redacted.
* @private
*/
private applyEventAsRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void {
const threadRootId = redactedEvent.threadRootId;
redactedEvent.makeRedacted(redactionEvent, this);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(),
redactedEvent.getStateKey()!,
);
if (currentStateEvent?.getId() === redactedEvent.getId()) {
this.currentState.setStateEvents([redactedEvent]);
}
}
this.emit(RoomEvent.Redaction, redactionEvent, this, threadRootId);
// TODO: we stash user displaynames (among other things) in
// RoomMember objects which are then attached to other events
// (in the sender and target fields). We should get those
// RoomMember objects to update themselves when the events that
// they are based on are changed.
// Remove any visibility change on this event.
this.visibilityEvents.delete(redactedEvent.getId()!);
// If this event is a visibility change event, remove it from the
// list of visibility changes and update any event affected by it.
if (redactedEvent.isVisibilityEvent()) {
this.redactVisibilityChangeEvent(redactionEvent);
}
}
private tryApplyRedaction = (event: MatrixEvent): void => {
// FIXME: apply redactions to notification list
// NB: We continue to add the redaction event to the timeline at the
// end of this function so clients can say "so and so redacted an event"
// if they wish to. Also this may be needed to trigger an update.
if (event.isRedaction()) {
const redactId = event.event.redacts;
// if we know about this event, redact its contents now.
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
if (redactedEvent) {
const threadRootId = redactedEvent.threadRootId;
redactedEvent.makeRedacted(event, this);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(),
redactedEvent.getStateKey()!,
);
if (currentStateEvent?.getId() === redactedEvent.getId()) {
this.currentState.setStateEvents([redactedEvent]);
}
}
this.emit(RoomEvent.Redaction, event, this, threadRootId);
// TODO: we stash user displaynames (among other things) in
// RoomMember objects which are then attached to other events
// (in the sender and target fields). We should get those
// RoomMember objects to update themselves when the events that
// they are based on are changed.
// Remove any visibility change on this event.
this.visibilityEvents.delete(redactId!);
// If this event is a visibility change event, remove it from the
// list of visibility changes and update any event affected by it.
if (redactedEvent.isVisibilityEvent()) {
this.redactVisibilityChangeEvent(event);
}
this.applyEventAsRedaction(event, redactedEvent);
}
} else if (event.getType() === EventType.RoomMember) {
const membership = event.getContent()["membership"];
if (
membership !== KnownMembership.Ban &&
!(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())
) {
// Not a ban or kick, therefore not a membership event we care about here.
return;
}
const redactEvents = event.getContent()["org.matrix.msc4293.redact_events"];
if (redactEvents !== true) {
// Invalid or not set - nothing to redact.
return;
}
const state = this.getLiveTimeline().getState(Direction.Forward)!;
if (!state.maySendRedactionForEvent(event, event.getSender()!)) {
// If the sender can't redact the membership event, then they won't be able to
// redact any of the target's events either, so skip.
return;
}
// FIXME: apply redactions to notification list
// NB: We continue to add the redaction event to the timeline so
// clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update.
// The redaction is possible, so let's find all the events and apply it.
const events = this.getTimelineSets()
.map((s) => s.getTimelines())
.reduce((p, c) => {
p.push(...c);
return p;
}, [])
.map((t) => t.getEvents().filter((e) => e.getSender() === event.getStateKey()))
.reduce((p, c) => {
p.push(...c);
return c;
}, []);
for (const toRedact of events) {
this.applyEventAsRedaction(event, toRedact);
}
}
};
private processLiveEvent(event: MatrixEvent): void {
this.applyRedaction(event);
this.tryApplyRedaction(event);
// Implement MSC3531: hiding messages.
if (event.isVisibilityEvent()) {
+4 -1
View File
@@ -139,6 +139,7 @@ export class OidcTokenRefresher {
profile: this.idTokenClaims,
};
const requestStart = Date.now();
const response = await this.oidcClient.useRefreshToken({
state: refreshTokenState,
timeoutInSeconds: 300,
@@ -147,7 +148,9 @@ export class OidcTokenRefresher {
const tokens = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
};
// We use the request start time to calculate the expiry time as we don't know when the server received our request
expiry: response.expires_in ? new Date(requestStart + response.expires_in * 1000) : undefined,
} satisfies AccessTokens;
await this.persistTokens(tokens);
+12 -2
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils.ts";
import { logger } from "./logger.ts";
import { type Logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
import { type MatrixEvent } from "./models/event.ts";
import {
@@ -170,6 +170,7 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
/**
* Make sure that each of the rules listed in `defaultRuleIds` is listed in the given set of push rules.
*
* @param logger - A `Logger` to write log messages to.
* @param kind - the kind of push rule set being merged.
* @param incomingRules - the existing set of known push rules for the user.
* @param defaultRules - a lookup table for the default definitions of push rules.
@@ -178,6 +179,7 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
* @returns A copy of `incomingRules`, with any missing default rules inserted in the right place.
*/
function mergeRulesWithDefaults(
logger: Logger,
kind: PushRuleKind,
incomingRules: IPushRule[],
defaultRules: Record<string, IPushRule>,
@@ -276,11 +278,17 @@ export class PushProcessor {
* Rewrites conditions on a client's push rules to match the defaults
* where applicable. Useful for upgrading push rules to more strict
* conditions when the server is falling behind on defaults.
*
* @param logger - A `Logger` to write log messages to.
* @param incomingRules - The client's existing push rules
* @param userId - The Matrix ID of the client.
* @returns The rewritten rules
*/
public static rewriteDefaultRules(incomingRules: IPushRules, userId: string | undefined = undefined): IPushRules {
public static rewriteDefaultRules(
logger: Logger,
incomingRules: IPushRules,
userId: string | undefined = undefined,
): IPushRules {
let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone
// These lines are mostly to make the tests happy. We shouldn't run into these
@@ -292,6 +300,7 @@ export class PushProcessor {
// Merge the client-level defaults with the ones from the server
newRules.global.override = mergeRulesWithDefaults(
logger,
PushRuleKind.Override,
newRules.global.override,
DEFAULT_OVERRIDE_RULES,
@@ -299,6 +308,7 @@ export class PushProcessor {
);
newRules.global.underride = mergeRulesWithDefaults(
logger,
PushRuleKind.Underride,
newRules.global.underride,
DEFAULT_UNDERRIDE_RULES,
+15 -14
View File
@@ -22,7 +22,7 @@ import {
import type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type BootstrapCrossSigningOpts } from "../crypto-api/index.ts";
import { logger } from "../logger.ts";
import { type Logger } from "../logger.ts";
import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
import { type UIAuthCallback } from "../interactive-auth.ts";
import { type ServerSideSecretStorage } from "../secret-storage.ts";
@@ -33,6 +33,7 @@ import { type ServerSideSecretStorage } from "../secret-storage.ts";
*/
export class CrossSigningIdentity {
public constructor(
private readonly logger: Logger,
private readonly olmMachine: OlmMachine,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
private readonly secretStorage: ServerSideSecretStorage,
@@ -61,7 +62,7 @@ export class CrossSigningIdentity {
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
// Log all relevant state for easier parsing of debug logs.
logger.log("bootstrapCrossSigning: starting", {
this.logger.debug("bootstrapCrossSigning: starting", {
setupNewCrossSigning: opts.setupNewCrossSigning,
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
@@ -71,23 +72,23 @@ export class CrossSigningIdentity {
if (olmDeviceHasKeys) {
if (!(await this.secretStorage.hasKey())) {
logger.warn(
this.logger.warn(
"bootstrapCrossSigning: Olm device has private keys, but secret storage is not yet set up; doing nothing for now.",
);
// the keys should get uploaded to 4S once that is set up.
} else if (!privateKeysInSecretStorage) {
// the device has the keys but they are not in 4S, so update it
logger.log("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
this.logger.debug("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
await this.exportCrossSigningKeysToStorage();
} else {
logger.log(
this.logger.debug(
"bootstrapCrossSigning: Olm device has private keys and they are saved in secret storage; doing nothing",
);
}
} /* (!olmDeviceHasKeys) */ else {
if (privateKeysInSecretStorage) {
// they are in 4S, so import from there
logger.log(
this.logger.debug(
"bootstrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
"in secret storage, reading storage and caching locally",
);
@@ -116,7 +117,7 @@ export class CrossSigningIdentity {
device.free();
}
} else {
logger.log(
this.logger.debug(
"bootstrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
);
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
@@ -125,7 +126,7 @@ export class CrossSigningIdentity {
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
logger.log("bootstrapCrossSigning: complete");
this.logger.debug("bootstrapCrossSigning: complete");
}
/** Reset our cross-signing keys
@@ -142,7 +143,7 @@ export class CrossSigningIdentity {
// If 4S is configured we need to update it.
if (!(await this.secretStorage.hasKey())) {
logger.warn(
this.logger.warn(
"resetCrossSigning: Secret storage is not yet set up; not exporting keys to secret storage yet.",
);
// the keys should get uploaded to 4S once that is set up.
@@ -150,11 +151,11 @@ export class CrossSigningIdentity {
// Update 4S before uploading cross-signing keys, to stay consistent with legacy that asks
// 4S passphrase before asking for account password.
// Ultimately should be made atomic and resistant to forgotten password/passphrase.
logger.log("resetCrossSigning: exporting private keys to secret storage");
this.logger.debug("resetCrossSigning: exporting private keys to secret storage");
await this.exportCrossSigningKeysToStorage();
}
logger.log("resetCrossSigning: publishing public keys to server");
this.logger.debug("resetCrossSigning: publishing public keys to server");
for (const req of [
outgoingRequests.uploadKeysRequest,
outgoingRequests.uploadSigningKeysRequest,
@@ -178,17 +179,17 @@ export class CrossSigningIdentity {
if (exported?.masterKey) {
await this.secretStorage.store("m.cross_signing.master", exported.masterKey);
} else {
logger.error(`Cannot export MSK to secret storage, private key unknown`);
this.logger.error(`Cannot export MSK to secret storage, private key unknown`);
}
if (exported?.self_signing_key) {
await this.secretStorage.store("m.cross_signing.self_signing", exported.self_signing_key);
} else {
logger.error(`Cannot export SSK to secret storage, private key unknown`);
this.logger.error(`Cannot export SSK to secret storage, private key unknown`);
}
if (exported?.userSigningKey) {
await this.secretStorage.store("m.cross_signing.user_signing", exported.userSigningKey);
} else {
logger.error(`Cannot export USK to secret storage, private key unknown`);
this.logger.error(`Cannot export USK to secret storage, private key unknown`);
}
}
}

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