Compare commits

...

127 Commits

Author SHA1 Message Date
RiotRobot 455b614008 v40.0.0-rc.0 2026-01-06 14:01:30 +00:00
Will Hunt 93f4f40202 Implement MSC4387: M_SAFETY error (#5107)
* Implement MatrixSafetyError

* Mention safety error on associated functions.

* fix import

* move error

* cleanup and add test

* wording

* fix test

* fixup error

* Fix exp
2026-01-06 13:04:52 +00:00
Richard van der Hoff aeade9ce58 Remove unused property MatrixEvent.untrusted (#5118)
* Remove unused property MatrixEvent.untrusted

This was never set to anything other than `false`. I think it is a hangover
from pre-rust-sdk.

* Remove call to redundant `isKeySourceUntrusted`

`isKeySourceUntrusted` always returns false so no point calling it

* Remove dangling assignments to MatrixEvent.untrusted
2026-01-06 11:23:13 +00:00
Timo 4b89fb23c5 MatrixRTC Pseudonymous livekit identities (#5110)
* deprecate membershipID -> memberId & memberId -> stateKey in membership
manager

The membership manager used the memberId label for the stateKey. But
only the StickymembershipManager really has a configurable memberId.

* participantId -> callMembershipIdentityParts

The participantId is a termonology from livekit. We do not want it in
here! We want the js-sdk to be mostly transport agnostic. We do the
transition from the identity parts to the acutal livekit identity in
Element call (`sha256(userId+deviceId+memberId)`)

* update tests

* Expose `kind` to decide if we use the hashed or non hashed livekit
participants.

* expose delayId from the matrixRTCSession for delayed event delegation.

* rename if to mapKey

* backandId computation as part of the js-sdk

* review valere

* valr + timo keysWithoutMatchingRTCMembership

* fix legacy encryption manager

* fix doc issue

* fix doc

* fix imports

* Encryption Manager needs own rtcBackendIdentity to use

The encryption manager needs to signal our own key fast, cannot wait for remote echo of rtc membership. So it needs to be able to compute the rtcBackendIdentity

* fix test

* Remove double `useHashedRtcBackendIdentity` assignment. rename
variables.

* little improvements This stops the usage from the matrix event outside
the CallMemerbship constructor.

* fix logger import

* Add back deprecated API for compat

* Make change to CallMembership constructor backward compatible

* more backward compatible

---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
2026-01-05 18:40:11 +00:00
Robin 174439c2f0 Make MatrixRTC encryption key types narrower for TS 5.9 compatibility (#5117)
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-9.html#libdts-changes

TypeScript 5.9 changes some things about the ArrayBuffer type and makes a number of DOM types, including the subtle crypto APIs, require a narrower buffer type as their input. For example if you wanted to use crypto.subtle.importKey to convert a MatrixRTC encryption key buffer given by matrix-js-sdk to a CryptoKey, you would run into a type error with TS 5.9. Specifying the type parameter of Uint8Array everywhere around the MatrixRTC files fixes this breakage.
2026-01-05 17:42:21 +00:00
Richard van der Hoff 43f3e10f05 Improve documentation on rawDisplayName (#5114)
... since it's not actually the raw displayname, at all.
2025-12-18 14:57:14 +00:00
Andy Balaam 97fcdb2830 Make the enableEncryptedStateEvents property on MatrixClient public (#5113)
* Make the enableEncryptedStateEvents property on MatrixClient public

* fixup! Make the enableEncryptedStateEvents property on MatrixClient public

tsdoc for enableEncryptedStateEvents

* fixup! Make the enableEncryptedStateEvents property on MatrixClient public

Improve the description of enableEncryptedStateEvents
2025-12-18 13:57:20 +00:00
Andy Balaam 31e2d8eb20 Re-check outgoing requests after processing them (#5109)
... in case any new requests have been added during processing.
Fixes https://github.com/element-hq/element-web/issues/30988
2025-12-17 12:32:04 +00:00
Richard van der Hoff 633a5a8848 Mark forwardingCurve25519KeyChain as deprecated (#5111)
The Rust SDK always populates this as an empty array, so we may as well get rid
of it.
2025-12-16 14:05:26 +00:00
Richard van der Hoff a5086a09b9 Mark IEventDecryptionResult as deprecated (#5112)
This is supposed to be js-sdk-internal
2025-12-16 14:05:16 +00:00
RiotRobot c251be9ae5 Merge branch 'master' into develop 2025-12-16 13:51:14 +00:00
RiotRobot ec137cb5fb v39.4.0 2025-12-16 13:50:28 +00:00
David Baker ab4e24f115 Make token refresher init itself lazily (#5106)
* Make token refresher init itself lazily

It needs a network connection to do the init, so this would fail if
a client tried to do it at startup with no internet, causing the token
to just never be refreshed.

This just changes the API (compatibly) to do the init lazily.

The promise is kept is retain backwards compat, it can be removed
later.

* Make deviceId protected

* Fix tests
2025-12-12 18:23:43 +00:00
Will Hunt 2218ec4e31 Add _unstable_getRTCTransports to client. (#5104) 2025-12-11 17:35:54 +00:00
Michael Telatynski 319a8309c5 Update SonarCloud workflow action version 2025-12-11 16:35:10 +00:00
Timo 5af046f54f Use membershipID for session events (#5105)
* User membershipID for session events

* fix tests
2025-12-11 12:40:47 +00:00
Timo f97a9d9762 Remove three status cases that will never be set. (#5103) 2025-12-10 17:47:10 +00:00
renovate[bot] dc1a57a9f2 Update typescript-eslint monorepo to v8.48.1 (#5100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 17:52:39 +00:00
renovate[bot] 2d6111a04b Update actions/setup-node digest to 395ad32 (#5097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 16:14:08 +00:00
renovate[bot] 710fd7859d Update dependency eslint-plugin-jsdoc to v61.5.0 (#5101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:15:42 +00:00
renovate[bot] e340a4ceaf Update dependency @matrix-org/matrix-sdk-crypto-wasm to v16 (#5102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:15:31 +00:00
renovate[bot] 3fa44e076e Update actions/stale digest to 9971854 (#5098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:13:49 +00:00
renovate[bot] 3f9fb9c936 Update matrix-org/sonarcloud-workflow-action digest to 820f7c2 (#5099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:13:35 +00:00
renovate[bot] 8db347a75e Update actions/checkout digest to 8e8c483 (#5096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:13:17 +00:00
renovate[bot] 8db3343280 Update dependency prettier to v3.7.0 (#5090)
* Update dependency prettier to v3.7.0

* Prettier

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-09 15:12:27 +00:00
renovate[bot] 25c5a5b4ff Update dependency @casualbot/jest-sonar-reporter to v2.5.0 (#5003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:09:30 +00:00
RiotRobot a696e77652 v39.4.0-rc.0 2025-12-09 14:51:21 +00:00
Andy Balaam 582a76d87c Update encrypted state to say MSC4362 everywhere (#5079)
* Update encrypted state to say MSC4362 everywhere

* Fix test failure with encrypted state: handle empty string state key
2025-12-09 12:47:38 +00:00
Skye Elliot fdfddde55a Import room key bundles received after invite. (#5080)
* feat: Import room key bundles when received after invite.

* tests: Add spec test for room key bundle arriving after invite accepted.

* chore: Fix code quality issue (unnecessary async function).

* docs: Tidy up comments.

* refactor: Simplify key bundle importing after invite to one entrypoint.

- Remove `onReceiveToDeviceEvent` from `CryptoBackend`.
- Copy old room key bundle importing logic to
  `preprocessToDeviceEvents`.

* refactor: Move late bundle importing to main preprocess loop.

* fix: Use `Map` over `Record` to prevent prototype pollution.
2025-12-08 17:50:13 +00:00
Michael Telatynski 0ecfef2352 Update SonarCloud workflow action version 2025-12-04 13:22:19 +00:00
Michael Telatynski 4fdece6c1c Update SonarCloud action version in workflow 2025-12-04 13:14:18 +00:00
Michael Telatynski 6f0bce8708 Add label to skip Sonar coverage (#5094)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-04 12:22:38 +00:00
Michael Telatynski d3bdeb73f5 Avoid use of Optional type (#5093)
* Avoid use of Optional type

As we are likely to remove dependency on matrix-events-sdk

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

* Tweak params

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

* Prettier

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

* Update test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-04 11:27:43 +00:00
RiotRobot 942fdf5bee Merge branch 'master' into develop 2025-12-02 14:45:10 +00:00
RiotRobot dd2635dbe6 v39.3.0 2025-12-02 14:44:30 +00:00
Will Hunt 3d1bcb73c1 Allow msc4354_sticky_key to be optional on sticky events. (#5073) 2025-11-27 11:54:49 +00:00
Michael Telatynski a960e686b3 Handle all response fields from /context API being optional (#5089)
* Handle all response fields from /context API being optional

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

* Simplify

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-27 11:12:39 +00:00
Michael Telatynski 946774c3fb Fix close-if-fork-develop job permissions (#5088)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-26 10:27:02 +00:00
renovate[bot] 15edbc8067 Update dependency @stylistic/eslint-plugin to v5.6.1 (#5083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:48:46 +00:00
renovate[bot] 1398ac24a2 Update typescript-eslint monorepo to v8.47.0 (#5086)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:48:38 +00:00
renovate[bot] c76df4cd8f Update all non-major dependencies (#5082)
* Update all non-major dependencies

* Make knip happy

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-25 15:43:23 +00:00
renovate[bot] a5e4dbf2d3 Update dependency matrix-widget-api to v1.15.0 (#5084)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:58:14 +00:00
renovate[bot] 3768187395 Update eslint-plugins (#5085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:58:04 +00:00
renovate[bot] 08d0ce25f1 Update actions/checkout action to v6 (#5087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:57:19 +00:00
RiotRobot 23241f18e2 v39.3.0-rc.0 2025-11-25 14:10:43 +00:00
Michael Telatynski 90da67aa95 Re-add truthy check on room name/avatar/alias events (#5081)
* Re-add truthy check on room name/avatar/alias events

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

* Add regression test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-25 09:09:11 +00:00
renovate[bot] 0bf2702149 Update jest to v30 (major) (#4875)
* Update jest to v30

* Update snapshots & imports

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

* Make jest happier

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

* Fix tests

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-24 09:07:13 +00:00
RiotRobot c7a75c8824 Merge branch 'master' into develop 2025-11-18 14:27:42 +00:00
RiotRobot 98b2b9745d v39.2.0 2025-11-18 14:26:56 +00:00
Michael Telatynski 65d5b3172c Fix invalid state events corrupting room objects (#5078)
* Fix invalid room name/canonical alias corrupting room objects

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

* Iterate

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-11-18 10:46:24 +00:00
renovate[bot] 2f72f9e889 Update dependency eslint-plugin-jsdoc to v61.1.12 (#5074)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:30:01 +00:00
renovate[bot] 18f500a1f8 Update all non-major dependencies (#5076)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:01:33 +00:00
renovate[bot] b1df58796a Update typescript-eslint monorepo to v8.46.3 (#5075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:01:12 +00:00
RiotRobot 761b3771d6 v39.2.0-rc.0 2025-11-11 12:44:05 +00:00
Andrew Ferrazzutti df88edfda0 Delayed event management: split endpoints, no auth (#5066)
* Delayed event management: split endpoints, no auth

Add dedicated endpoints for each of the cancel/restart/send actions for
updating a delayed event, and make them unauthenticated.

Also keep support for the original endpoint where the update action is
in the request body, and make the split-endpoint versions fall back to
it if they are unsupported by the homeserver.

* Don't @link parameters in method docstrings

as TypeDoc doesn't support that

* Reduce code duplication

* Reduce code duplication again

* Add a little more test coverage

* Use split delayed event management for widgets

* Specify which eslint rule to ignore

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>

* Restore embedded non-split delay evt update method

Keep supporting it to not break widgets that currently use it.
Also add back the test for it.

* Deprecate the non-split delay evt update methods

* Comment to explain fallback to non-split endpoint

* Add backwards compatibility with authed endpoints

* Comment backwards compatibility helper method

* Await returned promises

because `return await promise` is at least as fast as `return promise`

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
2025-11-11 05:54:33 +00:00
David Langley 1dee1ba581 Fix media switching during legacy calls (#5069)
* Specify exact for deviceId

* Update mediaHandler.spec.ts

* fallback to ideal if exact fails

* Reduce cognitive complexity for sonar

* Add tests
2025-11-10 09:36:32 +00:00
pkuzco b274c74a30 do not set cache in authenticated fetch (#5020)
* do not set cache in authenticated fetch

* issue 5019 - updated comment

* do not set cache in authenticated fetch

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

* issue 5019 - updated comment

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

---------

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>
Co-authored-by: Bahaa Naamneh <glimm.no@gmail.com>
2025-11-06 14:36:27 +00:00
RiotRobot b489bb15cf Merge branch 'master' into develop 2025-11-04 14:02:00 +00:00
RiotRobot dff4922a42 v39.1.2 2025-11-04 14:01:14 +00:00
Michael Telatynski dc6ad0b54c Fix npm publish script (#5068)
The `id` is also an output from this workflow so needs re-adding, not sure how the token stuck around, I thought I removed that
2025-11-04 13:33:55 +00:00
RiotRobot 9769c05dc5 Merge branch 'master' into develop 2025-11-04 13:23:41 +00:00
RiotRobot dd379d3d4c v39.1.1 2025-11-04 13:22:58 +00:00
Michael Telatynski 1b884a3e52 Fix npm release missing template braces (#5067) 2025-11-04 13:00:29 +00:00
RiotRobot ddb164490e Merge branch 'master' into develop 2025-11-04 12:54:27 +00:00
RiotRobot 796135c7ce v39.1.0 2025-11-04 12:53:45 +00:00
Michael Telatynski 4cc4c01dd8 Remove call to npm dist-tag (#5065)
As it does not work for NPM Trusted Publishing

https://github.com/npm/cli/issues/8547

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-30 09:34:14 +00:00
RiotRobot 533b40922c v39.1.0-rc.1 2025-10-30 08:45:29 +00:00
Michael Telatynski 0ae483ce27 Use NPM Trusted Publishers over token (#5064)
* Use NPM Trusted Publishers over token

due to security changes being enacted next month by npm

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

* Update npm as ubuntu-latest only has 10.x and 11.5.1 or later is necessary

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-29 16:07:11 +00:00
renovate[bot] dbc1fa87ed Update dependency eslint-plugin-jsdoc to v61.1.8 (#5058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 23:03:02 +00:00
renovate[bot] 0a3675b971 Update typescript-eslint monorepo to v8.46.2 (#5059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 16:11:40 +00:00
renovate[bot] ab3f529d29 Update babel monorepo to v7.28.5 (#5057)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:57:44 +00:00
renovate[bot] 607b712a07 Update dependency sdp-transform to v3 (#5062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:32 +00:00
renovate[bot] b6d9e49277 Update dependency @stylistic/eslint-plugin to v5.5.0 (#5061)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:17 +00:00
renovate[bot] 731d5943e2 Update GitHub Artifact Actions (#5063)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:08 +00:00
renovate[bot] 01e7a43593 Update all non-major dependencies (#5060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:53:35 +00:00
RiotRobot b69a19ce7c v39.1.0-rc.0 2025-10-28 14:03:05 +00:00
Valere Fedronic 8703acb533 MatrixRTC: Disable room transport fallback for keys (#4929)
* matrixRTC: Disable room transport fallback for keys

* post rebase fix

* post merge fix

---------

Co-authored-by: Will Hunt <will@half-shot.uk>
2025-10-28 09:03:15 +00:00
Timo b59603d748 [MatrixRTC] Sticky Events support (MSC4354) (#5017)
* Implement Sticky Events MSC

* Renames

* lint

* some review work

* Update for support for 4-ples

* fix lint

* pull through method

* Fix the mistake

* More tests to appease SC

* Cleaner code

* Review cleanup

* Refactors based on review.

* lint

* Add sticky event support to the js-sdk

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

* use sticky events for matrixRTC

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

* make sticky events a non breaking change (default to state events. use joinConfig to use sticky events)

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

* review
 - fix types (`msc4354_sticky:number` -> `msc4354_sticky?: { duration_ms: number };`)
  - add `MultiKeyMap`

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

* Refactor all of this away to it's own accumulator and class.

* Add tests

* tidyup

* more test cleaning

* lint

* Updates and tests

* fix filter

* fix filter with lint

* Add timer tests

* Add tests for MatrixRTCSessionManager

* Listen for sticky events on MatrixRTCSessionManager

* fix logic on filtering out state events

* lint

* more lint

* tweaks

* Add logging in areas

* more debugging

* much more logging

* remove more logging

* Finish supporting new MSC

* a line

* reconnect the bits to RTC

* fixup more bits

* fixup testrs

* Ensure consistent order

* lint

* fix log line

* remove extra bit of code

* revert changes to room-sticky-events.ts

* fixup mocks again

* lint

* fix

* cleanup

* fix paths

* tweak test

* fixup

* Add more tests for coverage

* Small improvements

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

* review

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

* Document better

* fix sticky event type

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

* fix demo

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

* fix tests

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

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* cleanup

* lint

* fix ci

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Half-Shot <will@half-shot.uk>
Co-authored-by: Robin <robin@robin.town>
2025-10-23 14:56:54 +00:00
Richard van der Hoff b0cbe22f64 Add CryptoApi.getSecretStorageStatus (#5054)
* Add `CryptoApi.getSecretStorageStatus`

`isSecretStorageReady` is a bit of a blunt instrument: it's hard to see from
logs *why* the secret storage isn't ready.

Add a new method which returns a bit more data.

* Update src/rust-crypto/rust-crypto.ts

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2025-10-23 11:04:28 +00:00
Timo 977d0322da Add parseCallNotificationContent (#5015)
* add parseCallNotificationContent

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

* add tests

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

* remove decline reason and better m.mentions check

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

* cap ring duration to EX value (90s)

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-21 13:02:14 +00:00
RiotRobot dd7394c14c Merge branch 'master' into develop 2025-10-21 11:19:14 +00:00
RiotRobot f2d082064e v39.0.0 2025-10-21 11:18:29 +00:00
Andrew Ferrazzutti 2731e20893 MSC4140: support filters on delayed event lookup (#5038)
* MSC4140: support filters on delayed event lookup

Support looking up scheduled/finalised delayed events, and looking up a
single delayed event.

* Add test coverage for delayed event lookup filters

* Prettier

* Use it.each for test loop

* Support multiple delayIds

* Support single or multiple delayIds

As it may be more common to look up a single delayed event than to look
up many of them, support passing a single delayID in the lookup function
instead of needing to pass a single-element array.
2025-10-20 14:59:29 +00:00
renovate[bot] 502a513b5b Update eslint-plugins (#5050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 10:28:30 +00:00
renovate[bot] 3ac47e71cd Update eslint-plugins (#5049)
* Update eslint-plugins

* Bump matrix-org

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-15 09:49:42 +00:00
renovate[bot] 7c1e25e713 Update actions/setup-node action to v6 (#5047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 16:28:15 +00:00
renovate[bot] 2d90ad95f1 Update peter-evans/repository-dispatch action to v4 (#5048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:19:11 +00:00
renovate[bot] cd9794471f Update dependency @types/node to v18.19.130 (#5045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:17:41 +00:00
renovate[bot] b2d3ab8bc1 Update typescript-eslint monorepo to v8.46.0 (#5046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:17:11 +00:00
renovate[bot] d8b70ef83b Update actions/stale digest to 5f858e3 (#5043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:16:55 +00:00
renovate[bot] a67fb1fb8d Update all non-major dependencies (#5044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:13:48 +00:00
Will Hunt ddd6e77cde Handle redactions for sticky events (MSC4354) (#5037)
* Handle redactions.

* Add unit tests

* Refactor to support unstable insertion orders.

* lint

* tidy tidy

* Fix doc

* lint

* Assert userId

* Catch one occurance of sticky event errors not being caught
2025-10-14 13:49:31 +00:00
RiotRobot 2e9f5b6033 v39.0.0-rc.0 2025-10-14 13:34:57 +00:00
Timo fd949fe486 [MatrixRTC] Multi SFU support + m.rtc.member event type support (#5022)
* WIP

* temp

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

* Fix imports

* Fix checkSessionsMembershipData thinking foci_preferred is required

* incorporate CallMembership changes
 - rename Focus -> Transport
 - add RtcMembershipData (next to `sessionMembershipData`)
 - make `new CallMembership` initializable with both
 - move oldest member calculation into CallMembership

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

* use correct event type

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

* fix sonar cube conerns

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

* callMembership tests

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

* make test correct

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

* make sonar cube happy (it does not know about the type constraints...)

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

* remove created_ts from RtcMembership

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

* fix imports

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

* Update src/matrixrtc/IMembershipManager.ts

Co-authored-by: Robin <robin@robin.town>

* rename LivekitFocus.ts -> LivekitTransport.ts

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

* add details to `getTransport`

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

* review

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

* use DEFAULT_EXPIRE_DURATION in tests

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

* fix test `does not provide focus if the selection method is unknown`

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

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* Move `m.call.intent` into the `application` section for rtc member events.

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

* review on rtc object validation code.

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

* user id check

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

* review: Refactor RTC membership handling and improve error handling

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

* docstring updates

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

* add back deprecated `getFocusInUse` & `getActiveFocus`

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

* ci

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

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* lint

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

* make test less strict for ew tests

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

* Typescript downstream test adjustments

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

* err

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
2025-10-08 19:12:29 +00:00
Michael Telatynski 7b3aed8a47 Tidy knip config (#5036)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-08 14:19:25 +00:00
Will Hunt b84a73c7cc Implement Sticky Events MSC4354 (#5028)
* Implement Sticky Events MSC

* Renames

* lint

* some review work

* Update for support for 4-ples

* fix lint

* pull through method

* Fix the mistake

* More tests to appease SC

* Cleaner code

* Review cleanup

* Refactors based on review.

* lint

* Store sticky event expiry TS at insertion time.

* proper type
2025-10-07 17:24:10 +00:00
Michael Telatynski a03cf054a8 Only use the first 3 viaServers specified (#5034)
* Only use the first 3 viaServers specified

To avoid HTTP 414 URI Too Long error

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-10-07 13:14:13 +00:00
RiotRobot b3d217717a Merge branch 'master' into develop 2025-10-07 12:06:14 +00:00
RiotRobot 3e6fe5f914 v38.4.0 2025-10-07 12:05:30 +00:00
Andy Balaam a213d177f9 Fetch the user's device info before processing a verification request (#5030)
* Use checked way to get OlmMachine

* Factor out two variables in onKeyVerificationEvent

* Make sure verification test waits for the request to be processed

* Fetch the user's device info before processing a verification request

If we don't have the device info for a user when we receive their
verification request, we ignore it. This change gives us the best
possible chance of having the right device data before we try to process
the verification.

Fixes #30693
Fixes #27819
2025-10-07 08:13:31 +00:00
Will Hunt e885ecf08d Stabilise extended profiles (now part of Matrix v1.16) (#5013)
* Stabilise extended profiles (v1.16)

* lint

* Add capacity

* Update profile keys

* copyright

* lint
2025-10-06 15:10:15 +00:00
pkuzco d1d9aba745 feat(client): allow disabling VoIP support (#5021)
* feat(client): allow disabling VoIP support

* feat(client): allow disabling VoIP support

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

* add a unit-test for disableVoip option

* fix lint issue

---------

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>
Co-authored-by: Bahaa Naamneh <glimm.no@gmail.com>
2025-10-01 16:52:36 +00:00
Michael Telatynski 52bcc2c955 Update community PR check to exclude dependabot (#5029) 2025-10-01 12:01:33 +00:00
renovate[bot] 1994806c72 Update typescript-eslint monorepo to v8.44.1 (#5025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:31:31 +00:00
renovate[bot] c4d1fd2c67 Update guibranco/github-status-action-v2 digest to 5530c59 (#5023)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:18:48 +00:00
renovate[bot] 7ad8288525 Update dependency @types/node to v18.19.127 (#5024)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:08:41 +00:00
renovate[bot] 31a42964e6 Update all non-major dependencies (#5026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:07:57 +00:00
renovate[bot] 2b1d37813c Update dependency @stylistic/eslint-plugin to v5.4.0 (#5027)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:07:29 +00:00
RiotRobot e6fd0c58ff v38.4.0-rc.0 2025-09-30 12:41:53 +00:00
Will Hunt 41d70d0b5d Add call intent to RTC call notifications (#5010)
* Add media hint specifier

* Refactor to use m.call.intent and to apply to membership

* lint

* Add a mechanism to get the consensus of a call.

* Update tests

* Expose option to update the call intent.

* Better docs

* Add tests

* lint
2025-09-25 09:02:35 +00:00
Skye Elliot a08a2737e1 Implement experimental encrypted state events. (#4994)
* feat: Implement experimental encrypted state events.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* fix: Add cast from StateEvents[K] to IContent.

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2025-09-24 11:44:17 +00:00
Andy Balaam dbe441de33 Exclude cancelled requests from in-progress lists (#5016)
Fixes https://github.com/element-hq/element-web/issues/29882

When we ask for the in-progress verification requests, exclude requests
that have been cancelled. This means that we don't erroneously tell the
user that the new request they are about to create has been cancelled.
2025-09-23 13:51:19 +00:00
RiotRobot 9f3ca71495 Merge branch 'master' into develop 2025-09-23 12:13:04 +00:00
renovate[bot] 7f74fcc9f7 Update dependency debug to v4.4.3 (#5012)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 11:30:33 +00:00
renovate[bot] e39644ad08 Update dependency @stylistic/eslint-plugin to v5.3.1 (#5002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 09:49:25 +00:00
renovate[bot] df0f0074b4 Update dependency uuid to v13 (#5009)
* Update dependency uuid to v13

* Make jest happy

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 20:00:04 +00:00
renovate[bot] 29fbed5603 Update dependency p-retry to v7 (#5008)
* Update dependency p-retry to v7

* Iterate

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

* Make jest happier

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 18:45:28 +00:00
renovate[bot] 5ee6fc196b Update actions/stale action to v10 (#5007)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 16:23:39 +00:00
renovate[bot] 961e32a3bb Update actions/setup-node action to v5 (#5006)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 15:30:02 +00:00
renovate[bot] 25f0418fce Update babel monorepo to v7.28.4 (#4998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:10:18 +00:00
renovate[bot] b7b3588cb8 Update shogo82148/actions-upload-release-asset digest to 59cbc56 (#4996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:58:13 +00:00
renovate[bot] 72846c713d Update actions/github-script action to v8 (#5005)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:56:33 +00:00
renovate[bot] 7c5229b4c8 Update typescript-eslint monorepo to v8.43.0 (#5004)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:50:53 +00:00
renovate[bot] dd08388397 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v15.3.0 (#5001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:50:32 +00:00
renovate[bot] ec1ccebcca Update dependency typedoc to v0.28.13 (#5000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:46:32 +00:00
renovate[bot] e9b45cc504 Update dependency @types/node to v18.19.124 (#4999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:46:01 +00:00
renovate[bot] 5d4df65c09 Update all non-major dependencies (#4997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:45:36 +00:00
121 changed files with 9392 additions and 4412 deletions
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # 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@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+3
View File
@@ -41,3 +41,6 @@
- name: "Z-Flaky-Test"
description: "A test is raising false alarms"
color: "ededed"
- name: "Z-Skip-Coverage"
description: "Skip SonarQube coverage for this PR"
color: "ededed"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+6 -4
View File
@@ -35,7 +35,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -49,7 +49,7 @@ jobs:
pull-requests: write
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
if: github.event.pull_request.user.login != 'renovate[bot]' && github.event.pull_request.user.login != 'dependabot[bot]'
uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
id: teams
with:
@@ -60,7 +60,7 @@ jobs:
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
github.rest.issues.addLabels({
@@ -73,13 +73,15 @@ jobs:
close-if-fork-develop:
name: Forbid develop branch fork contributions
runs-on: ubuntu-24.04
permissions:
pull-requests: write
if: >
github.event.action == 'opened' &&
github.event.pull_request.head.ref == 'develop' &&
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
REPO: ${{ inputs.repository }}
with:
@@ -16,12 +16,12 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ref: staging
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version-file: package.json
cache: "yarn"
@@ -37,7 +37,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
+6 -10
View File
@@ -4,8 +4,6 @@ on:
secrets:
ELEMENT_BOT_TOKEN:
required: true
NPM_TOKEN:
required: false
GPG_PASSPHRASE:
required: false
GPG_PRIVATE_KEY:
@@ -73,7 +71,7 @@ jobs:
draft: true
latest: true
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
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 +79,7 @@ jobs:
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -106,7 +104,7 @@ jobs:
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { VERSION } = process.env;
@@ -125,7 +123,7 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -218,7 +216,7 @@ jobs:
- name: Validate release has expected assets
if: inputs.expected-asset-count
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
@@ -246,7 +244,7 @@ jobs:
git push origin master
- name: Publish release
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }}
@@ -282,8 +280,6 @@ jobs:
permissions:
contents: read
id-token: write
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
post-release:
name: Post release steps
+8 -14
View File
@@ -1,9 +1,6 @@
name: Publish to npm
on:
workflow_call:
secrets:
NPM_TOKEN:
required: true
outputs:
id:
description: "The npm package@version string we published"
@@ -20,32 +17,29 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ref: staging
- name: 🔧 Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
registry-url: "https://registry.npmjs.org"
node-version-file: package.json
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
run: |
npm publish --provenance --access public --tag next
npm publish --provenance --access public --tag "$TAG"
release=$(jq -r '"\(.name)@\(.version)"' package.json)
echo "id=$release" >> $GITHUB_OUTPUT
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: 🎖️ Add `latest` dist-tag to final releases
if: steps.npm-publish.outputs.id && !contains(steps.npm-publish.outputs.id, '-rc.')
run: npm dist-tag add "$release" latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.id }}
TAG: ${{ contains(steps.npm-publish.outputs.id, '-rc.') && 'next' || 'latest' }}
+4 -4
View File
@@ -44,13 +44,13 @@ jobs:
repo:
- element-hq/element-web
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version: "lts/*"
@@ -73,10 +73,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: 🔧 Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
+7 -6
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@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -36,14 +36,14 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: 📥 Download artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -75,19 +75,20 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
uses: matrix-org/sonarcloud-workflow-action@9d9692c97a426ea43f5b578c6bc8f952a867cd49
# 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:
skip_checkout: true
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
skip_coverage_label: Z-Skip-Coverage
version_cmd: "cat package.json | jq -r .version"
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -31,9 +31,9 @@ jobs:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -61,7 +61,7 @@ jobs:
- name: Build Types
run: "yarn build:types"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: docs
path: _docs
@@ -125,9 +125,9 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: element-hq/element-web
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node
id: setupNode
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version: ${{ matrix.node }}
@@ -57,7 +57,7 @@ jobs:
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
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@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
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@5bef64f19d7facfb25b37b414482c7164d639639 # v9
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
operations-per-run: 250
days-before-issue-stale: -1
+82
View File
@@ -1,3 +1,85 @@
Changes in [39.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.4.0) (2025-12-16)
==================================================================================================
## ✨ Features
* Import room key bundles received after invite. ([#5080](https://github.com/matrix-org/matrix-js-sdk/pull/5080)). Contributed by @kaylendog.
## 🐛 Bug Fixes
* Allow msc4354\_sticky\_key to be optional on sticky events. ([#5073](https://github.com/matrix-org/matrix-js-sdk/pull/5073)). Contributed by @Half-Shot.
* Handle all response fields from /context API being optional ([#5089](https://github.com/matrix-org/matrix-js-sdk/pull/5089)). Contributed by @t3chguy.
Changes in [39.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.3.0) (2025-12-02)
==================================================================================================
## 🐛 Bug Fixes
* Re-add truthy check on room name/avatar/alias events ([#5081](https://github.com/matrix-org/matrix-js-sdk/pull/5081)). Contributed by @t3chguy.
* Fix invalid state events corrupting room objects ([#5078](https://github.com/matrix-org/matrix-js-sdk/pull/5078)). Contributed by @t3chguy.
Changes in [39.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.2.0) (2025-11-18)
==================================================================================================
## ✨ Features
* Delayed event management: split endpoints, no auth ([#5066](https://github.com/matrix-org/matrix-js-sdk/pull/5066)). Contributed by @AndrewFerr.
* do not set cache in authenticated fetch ([#5020](https://github.com/matrix-org/matrix-js-sdk/pull/5020)). Contributed by @pkuzco.
## 🐛 Bug Fixes
* Fix media switching during legacy calls ([#5069](https://github.com/matrix-org/matrix-js-sdk/pull/5069)). Contributed by @langleyd.
Changes in [39.1.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.2) (2025-11-04)
==================================================================================================
Re-release of v39.1.0 to fix npm publishing workflow
Changes in [39.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.1) (2025-11-04)
==================================================================================================
Re-release of v39.1.0 to fix npm publishing workflow
Changes in [39.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.0) (2025-11-04)
==================================================================================================
## ✨ Features
* [MatrixRTC] Sticky Events support (MSC4354) ([#5017](https://github.com/matrix-org/matrix-js-sdk/pull/5017)). Contributed by @toger5.
* Add `CryptoApi.getSecretStorageStatus` ([#5054](https://github.com/matrix-org/matrix-js-sdk/pull/5054)). Contributed by @richvdh.
* Add parseCallNotificationContent ([#5015](https://github.com/matrix-org/matrix-js-sdk/pull/5015)). Contributed by @toger5.
* MSC4140: support filters on delayed event lookup ([#5038](https://github.com/matrix-org/matrix-js-sdk/pull/5038)). Contributed by @AndrewFerr.
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
==================================================================================================
## 🚨 BREAKING CHANGES
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
## ✨ Features
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
## 🐛 Bug Fixes
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
==================================================================================================
## ✨ Features
* Add call intent to RTC call notifications ([#5010](https://github.com/matrix-org/matrix-js-sdk/pull/5010)). Contributed by @Half-Shot.
* Implement experimental encrypted state events. ([#4994](https://github.com/matrix-org/matrix-js-sdk/pull/4994)). Contributed by @kaylendog.
## 🐛 Bug Fixes
* Exclude cancelled requests from in-progress lists ([#5016](https://github.com/matrix-org/matrix-js-sdk/pull/5016)). Contributed by @andybalaam.
Changes in [38.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.3.0) (2025-09-23)
==================================================================================================
## 🐛 Bug Fixes
+3 -4
View File
@@ -71,7 +71,7 @@ Unless otherwise specified, the following applies to all code:
11. If a variable is not receiving a value on declaration, its type must be defined.
```typescript
let errorMessage: Optional<string>;
let errorMessage: string;
```
12. Objects can use shorthand declarations, including mixing of types.
@@ -150,8 +150,7 @@ Unless otherwise specified, the following applies to all code:
1. When using `any`, a comment explaining why must be present.
27. `import` should be used instead of `require`, as `require` does not have types.
28. Export only what can be reused.
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
of truly optional parameters.
29. Prefer a type like `X | null` instead of truly optional parameters.
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
takes an argument that is more often not required than required. An example where the
`?` operator is inappropriate is when taking a room ID: typically the caller should
@@ -161,7 +160,7 @@ Unless otherwise specified, the following applies to all code:
```typescript
function doThingWithRoom(
thing: string,
room: Optional<string>, // require the caller to specify
room: string | null, // require the caller to specify
) {
// ...
}
+1
View File
@@ -23,6 +23,7 @@ const config: Config = {
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
transformIgnorePatterns: ["/node_modules/(?!(uuid|p-retry|is-network-error)).+$"],
// Always print out a summary if there are any failing tests. Normally
// a summary is only printed if there are more than 20 test *suites*.
+2 -4
View File
@@ -12,10 +12,6 @@ export default {
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
"scripts/**",
"spec/**",
// XXX: these look entirely unused
"src/crypto/aes.ts",
"src/crypto/crypto.ts",
"src/crypto/recoverykey.ts",
// XXX: these should be re-exported by one of the supported exports
"src/matrixrtc/index.ts",
"src/sliding-sync.ts",
@@ -38,6 +34,8 @@ export default {
"ts-node",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
// Transitive dep of jest
"jsdom",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
+17 -17
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "38.3.0",
"version": "40.0.0-rc.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -49,19 +49,19 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.2.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^16.0.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.10.0",
"matrix-widget-api": "^1.14.0",
"oidc-client-ts": "^3.0.1",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"p-retry": "7",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "11"
"uuid": "13"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -78,18 +78,18 @@
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@casualbot/jest-sonar-reporter": "2.5.0",
"@matrix-org/olm": "3.2.15",
"@peculiar/webcrypto": "^1.4.5",
"@stylistic/eslint-plugin": "^5.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/jest": "^29.0.0",
"@types/jest": "^30.0.0",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"babel-jest": "^29.0.0",
"babel-jest": "^30.0.0",
"babel-plugin-search-and-replace": "^1.1.1",
"debug": "^4.3.4",
"eslint": "8.57.1",
@@ -97,25 +97,25 @@
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-jest": "^29.0.0",
"eslint-plugin-jsdoc": "^61.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.4.0",
"eslint-plugin-tsdoc": "^0.5.0",
"eslint-plugin-unicorn": "^56.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "11.1.5",
"fetch-mock-jest": "^1.5.1",
"husky": "^9.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"jest-mock": "^30.0.0",
"knip": "^5.0.0",
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.6.2",
"prettier": "3.7.0",
"rimraf": "^6.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.28.1",
+1 -1
View File
@@ -403,7 +403,7 @@ describe("cross-signing", () => {
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
expect(isCrossSigningReady).toBeFalsy();
});
}, 10000);
});
describe("getCrossSigningKeyId", () => {
+17 -115
View File
@@ -88,7 +88,10 @@ import {
encryptMegolmEventRawPlainText,
establishOlmSession,
getTestOlmAccountKeys,
} from "./olm-utils";
expectSendRoomKey,
expectSendMegolmMessageEvent,
expectEncryptedSendMessageEvent,
} from "./olm-utils.ts";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
import { KnownMembership } from "../../../src/@types/membership";
@@ -104,107 +107,6 @@ afterEach(() => {
jest.useRealTimers();
});
/**
* Expect that the client shares keys with the given recipient
*
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
* to establish an Olm InboundGroupSession.
*
* @param recipientUserID - the user id of the expected recipient
*
* @param recipientOlmAccount - Olm.Account for the recipient
*
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
*
* @returns the established inbound group session
*/
async function expectSendRoomKey(
recipientUserID: string,
recipientOlmAccount: Olm.Account,
recipientOlmSession: Olm.Session | null = null,
): Promise<Olm.InboundGroupSession> {
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
function onSendRoomKey(content: any): Olm.InboundGroupSession {
const m = content.messages[recipientUserID].DEVICE_ID;
const ct = m.ciphertext[testRecipientKey];
if (!recipientOlmSession) {
expect(ct.type).toEqual(0); // pre-key message
recipientOlmSession = new Olm.Session();
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
} else {
expect(ct.type).toEqual(1); // regular message
}
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
expect(decrypted.type).toEqual("m.room_key");
const inboundGroupSession = new Olm.InboundGroupSession();
inboundGroupSession.create(decrypted.content.session_key);
return inboundGroupSession;
}
return await new Promise<Olm.InboundGroupSession>((resolve) => {
fetchMock.putOnce(
new RegExp("/sendToDevice/m.room.encrypted/"),
(url: string, opts: RequestInit): FetchMock.MockResponse => {
const content = JSON.parse(opts.body as string);
resolve(onSendRoomKey(content));
return {};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
}
/**
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
* @returns the content of the encrypted event
*/
function expectEncryptedSendMessage() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
return { event_id: "$event_id" };
},
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
{ overwriteRoutes: false },
);
});
}
/**
* Expect that the client sends an encrypted event
*
* Waits for an HTTP request to send an encrypted message in the test room.
*
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
*
* @returns The content of the successfully-decrypted event
*/
async function expectSendMegolmMessage(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedMessageContent = await expectEncryptedSendMessage();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
logger.log("Decrypted received megolm message", r);
return JSON.parse(r.plaintext);
}
describe("crypto", () => {
let testOlmAccount = {} as unknown as Olm.Account;
let testSenderKey = "";
@@ -991,7 +893,7 @@ describe("crypto", () => {
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
]);
});
@@ -1018,7 +920,7 @@ describe("crypto", () => {
// Send the first message, and check we can decrypt it.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
]);
// Finally the interesting part: discard the session.
@@ -1026,7 +928,7 @@ describe("crypto", () => {
// Now when we send the next message, we should get a *new* megolm session.
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2);
const p2 = expectSendMegolmMessageEvent(inboundGroupSessionPromise2);
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
});
@@ -1037,7 +939,7 @@ describe("crypto", () => {
*/
async function sendEncryptedMessage(): Promise<IContent> {
const [encryptedMessage] = await Promise.all([
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
aliceClient.sendTextMessage(ROOM_ID, "test"),
]);
return encryptedMessage;
@@ -1159,7 +1061,7 @@ describe("crypto", () => {
let [, , encryptedMessage] = await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
// Check that the session id exists
@@ -1187,7 +1089,7 @@ describe("crypto", () => {
[, , encryptedMessage] = await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
// Check that the new session id exists
@@ -1385,7 +1287,7 @@ describe("crypto", () => {
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
// kick it off
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
@@ -1408,7 +1310,7 @@ describe("crypto", () => {
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
// kick it off
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
@@ -2300,7 +2202,7 @@ describe("crypto", () => {
await syncPromise(client1);
// Send a message, and expect to get an `m.room.encrypted` event.
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]);
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessageEvent()]);
// We now replace the client, and allow the new one to resync, *without* the encryption event.
client2 = await replaceClient(client1);
@@ -2321,7 +2223,7 @@ describe("crypto", () => {
// Send a message, and expect to get an `m.room.encrypted` event.
const [, msg1Content] = await Promise.all([
client1.sendTextMessage(ROOM_ID, "test1"),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
// Replace the state with one which bumps the rotation period. This should be ignored, though it's not
@@ -2340,12 +2242,12 @@ describe("crypto", () => {
// use a different one.
const [, msg2Content] = await Promise.all([
client1.sendTextMessage(ROOM_ID, "test2"),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
expect(msg2Content.session_id).toEqual(msg1Content.session_id);
const [, msg3Content] = await Promise.all([
client1.sendTextMessage(ROOM_ID, "test3"),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
expect(msg3Content.session_id).not.toEqual(msg1Content.session_id);
});
@@ -2357,7 +2259,7 @@ describe("crypto", () => {
await syncPromise(client1);
// Send a message, and expect to get an `m.room.encrypted` event.
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]);
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessageEvent()]);
// We now replace the client, and allow the new one to resync with a *different* encryption event.
client2 = await replaceClient(client1);
+102 -1
View File
@@ -30,7 +30,7 @@ import {
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts";
import { SyncResponder } from "../../test-utils/SyncResponder.ts";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts";
import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts";
import { getSyncResponse, mkEventCustom, syncPromise, waitFor } from "../../test-utils/test-utils.ts";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
@@ -80,6 +80,9 @@ describe("History Sharing", () => {
let bobSyncResponder: SyncResponder;
beforeEach(async () => {
// Reset mocks.
fetchMock.reset();
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
@@ -201,6 +204,104 @@ describe("History Sharing", () => {
expect(event.getContent().body).toEqual("Hi!");
});
test("Room keys are imported correctly if invite is accepted before the bundle arrives", async () => {
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => {
const body = request.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
// Bob receives the room invite, but not the room key bundle
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
});
await syncPromise(bobClient);
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
}) as any,
);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeTruthy();
// Now the room key bundle message arrives
fetchMock.getOnce(
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
{ body: uploadedBlob },
{ sendAsJson: false },
);
bobSyncResponder.sendOrQueueSyncResponse({
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
// Once the room key bundle finishes downloading, we should be able to decrypt the message.
await waitFor(async () => {
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeFalsy();
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual("Hello!");
});
});
afterEach(async () => {
bobClient.stopClient();
aliceClient.stopClient();
+151
View File
@@ -16,6 +16,7 @@ limitations under the License.
import Olm from "@matrix-org/olm";
import anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import {
type IContent,
@@ -30,6 +31,8 @@ import { type IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { type ISyncResponder } from "../../test-utils/SyncResponder";
import { syncPromise } from "../../test-utils/test-utils";
import { type KeyBackupInfo } from "../../../src/crypto-api";
import { logger } from "../../../src/logger";
import type FetchMock from "fetch-mock";
/**
* @module
@@ -302,6 +305,9 @@ export function encryptMegolmEventRawPlainText(opts: {
},
type: "m.room.encrypted",
unsigned: {},
state_key: opts.plaintext.hasOwnProperty("state_key")
? `${opts.plaintext.type}:${opts.plaintext.state_key}`
: undefined,
};
}
@@ -414,3 +420,148 @@ export async function establishOlmSession(
await syncPromise(testClient);
return p2pSession;
}
/**
* Expect that the client shares keys with the given recipient
*
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
* to establish an Olm InboundGroupSession.
*
* @param recipientUserID - the user id of the expected recipient
*
* @param recipientOlmAccount - Olm.Account for the recipient
*
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
*
* @returns the established inbound group session
*/
export async function expectSendRoomKey(
recipientUserID: string,
recipientOlmAccount: Olm.Account,
recipientOlmSession: Olm.Session | null = null,
): Promise<Olm.InboundGroupSession> {
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
function onSendRoomKey(content: any): Olm.InboundGroupSession {
const m = content.messages[recipientUserID].DEVICE_ID;
const ct = m.ciphertext[testRecipientKey];
if (!recipientOlmSession) {
expect(ct.type).toEqual(0); // pre-key message
recipientOlmSession = new Olm.Session();
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
} else {
expect(ct.type).toEqual(1); // regular message
}
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
expect(decrypted.type).toEqual("m.room_key");
const inboundGroupSession = new Olm.InboundGroupSession();
inboundGroupSession.create(decrypted.content.session_key);
return inboundGroupSession;
}
return await new Promise<Olm.InboundGroupSession>((resolve) => {
fetchMock.putOnce(
new RegExp("/sendToDevice/m.room.encrypted/"),
(url: string, opts: RequestInit): FetchMock.MockResponse => {
const content = JSON.parse(opts.body as string);
resolve(onSendRoomKey(content));
return {};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
}
/**
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
* @returns the content of the encrypted event
*/
export function expectEncryptedSendMessageEvent() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
return { event_id: "$event_id" };
},
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
{ overwriteRoutes: false },
);
});
}
/**
* Return the event received on rooms/{roomId}/state/m.room.encrypted/{stateKey} endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
* @returns the content of the encrypted event
*/
function expectEncryptedSendStateEvent() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/state/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
return { event_id: "$event_id" };
},
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
{ overwriteRoutes: false },
);
});
}
/**
* Expect that the client sends an encrypted message event
*
* Waits for an HTTP request to send an encrypted message in the test room.
*
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
*
* @returns The content of the successfully-decrypted event
*/
export async function expectSendMegolmMessageEvent(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedMessageContent = await expectEncryptedSendMessageEvent();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
logger.log("Decrypted received megolm message", r);
return JSON.parse(r.plaintext);
}
/**
* Expect that the client sends an encrypted state event
*
* Waits for an HTTP request to send an encrypted state event in the test room.
*
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
*
* @returns The content of the successfully-decrypted state event
*/
export async function expectSendMegolmStateEvent(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedStateContent = await expectEncryptedSendStateEvent();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
const r: any = inboundGroupSession.decrypt(encryptedStateContent!.ciphertext);
logger.log("Decrypted received megolm state event", r);
return JSON.parse(r.plaintext);
}
+219
View File
@@ -0,0 +1,219 @@
/*
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 anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import Olm from "@matrix-org/olm";
import * as testUtils from "../../test-utils/test-utils";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
import { logger } from "../../../src/logger";
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import {
createOlmAccount,
createOlmSession,
encryptGroupSessionKey,
encryptMegolmEvent,
getTestOlmAccountKeys,
expectSendRoomKey,
expectSendMegolmStateEvent,
} from "./olm-utils";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
describe("Encrypted State Events", () => {
let testOlmAccount = {} as unknown as Olm.Account;
let testSenderKey = "";
/** the MatrixClient under test */
let aliceClient: MatrixClient;
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
let keyReceiver: E2EKeyReceiver;
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
let syncResponder: ISyncResponder;
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
logger.log(aliceClient.getUserId() + ": starting");
mockInitialApiRequests(aliceClient.getHomeserverUrl());
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
aliceClient.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached,
...opts,
});
await syncPromise(aliceClient);
logger.log(aliceClient.getUserId() + ": started");
}
beforeEach(async () => {
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: "@alice:localhost",
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
logger: logger.getChild("aliceClient"),
enableEncryptedStateEvents: true,
});
keyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
await aliceClient.initRustCrypto();
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
testOlmAccount = await createOlmAccount();
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
testSenderKey = testE2eKeys.curve25519;
}, 10000);
afterEach(async () => {
await aliceClient.stopClient();
await jest.runAllTimersAsync();
fetchMock.mockReset();
});
function expectAliceKeyQuery(response: any) {
fetchMock.postOnce(new RegExp("/keys/query"), (url: string, opts: RequestInit) => response, {
overwriteRoutes: false,
});
}
function expectAliceKeyClaim(response: any) {
fetchMock.postOnce(new RegExp("/keys/claim"), response);
}
function getTestKeysClaimResponse(userId: string) {
testOlmAccount.generate_one_time_keys(1);
const testOneTimeKeys = JSON.parse(testOlmAccount.one_time_keys());
testOlmAccount.mark_keys_as_published();
const keyId = Object.keys(testOneTimeKeys.curve25519)[0];
const oneTimeKey: string = testOneTimeKeys.curve25519[keyId];
const unsignedKeyResult = { key: oneTimeKey };
const j = anotherjson.stringify(unsignedKeyResult);
const sig = testOlmAccount.sign(j);
const keyResult = {
...unsignedKeyResult,
signatures: { [userId]: { "ed25519:DEVICE_ID": sig } },
};
return {
one_time_keys: { [userId]: { DEVICE_ID: { ["signed_curve25519:" + keyId]: keyResult } } },
failures: {},
};
}
it("Should receive an encrypted state event", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
recipient: aliceClient.getUserId()!,
recipientCurve25519Key: keyReceiver.getDeviceKey(),
recipientEd25519Key: keyReceiver.getSigningKey(),
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
// encrypt a state event with the group session
const eventEncrypted = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
plaintext: {
type: "m.room.topic",
state_key: "",
content: {
topic: "Secret!",
},
},
});
// Alice gets both the events in a single sync
const syncResponse = {
next_batch: 1,
to_device: {
events: [roomKeyEncrypted],
},
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [eventEncrypted] } },
},
},
};
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
expect(event.isEncrypted()).toBe(true);
// it probably won't be decrypted yet, because it takes a while to process the olm keys
const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(decryptedEvent.getContent().topic).toEqual("Secret!");
});
it("Should send an encrypted state event", async () => {
const homeserverUrl = aliceClient.getHomeserverUrl();
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
keyResponder.addDeviceKeys(testDeviceKeys);
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
await syncPromise(aliceClient);
// ... and claim one of Bob's OTKs ...
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// ... and send an m.room.topic message
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
await Promise.all([
aliceClient.setRoomTopic(ROOM_ID, "Secret!"),
expectSendMegolmStateEvent(inboundGroupSessionPromise),
]);
});
});
+78 -7
View File
@@ -46,8 +46,8 @@ import {
type Verifier,
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { escapeRegExp } from "../../../src/utils";
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { escapeRegExp, sleep } from "../../../src/utils";
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise, waitFor } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
BACKUP_DECRYPTION_KEY_BASE64,
@@ -79,11 +79,6 @@ import {
import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api";
import { encodeBase64 } from "../../../src/base64";
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
beforeAll(async () => {
// we use the libolm primitives in the test, so init the Olm library
await Olm.init();
@@ -96,6 +91,13 @@ beforeAll(async () => {
await RustSdkCryptoJs.initAsync();
}, 10000);
beforeEach(() => {
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
@@ -735,6 +737,35 @@ describe("verification", () => {
expect(request.cancellingUserId).toEqual("@alice:localhost");
});
it("does not include cancelled requests in the list of requests", async () => {
// Given Alice started a verification request
const [, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
await waitForVerificationRequestChanged(request);
// Sanity: the request is listed
const requestsBeforeCancel = aliceClient
.getCrypto()!
.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requestsBeforeCancel).toHaveLength(1);
// When Alice cancels it
await Promise.all([expectSendToDeviceMessage("m.key.verification.cancel"), request.cancel()]);
// Then it is no longer listed as in progress
const requestsAfterCancel = aliceClient
.getCrypto()!
.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requestsAfterCancel).toHaveLength(0);
});
it("can cancel during the SAS phase", async () => {
// have alice initiate a verification. She should send a m.key.verification.request
const [, request] = await Promise.all([
@@ -1051,6 +1082,13 @@ describe("verification", () => {
});
it("ignores old verification requests", async () => {
const debug = jest.fn();
const info = jest.fn();
const warn = jest.fn();
// @ts-ignore overriding RustCrypto's logger
aliceClient.getCrypto()!.logger = { debug, info, warn };
const eventHandler = jest.fn();
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
@@ -1065,6 +1103,16 @@ describe("verification", () => {
const matrixEvent = room.getLiveTimeline().getEvents()[0];
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
// Wait until the request has been processed. We use a real sleep()
// here to make sure any background async tasks are completed.
jest.useRealTimers();
await waitFor(async () => {
expect(info).toHaveBeenCalledWith(
expect.stringMatching(/^Ignoring just-received verification request/),
);
sleep(100);
});
// check that an event has not been raised, and that the request is not found
expect(eventHandler).not.toHaveBeenCalled();
expect(
@@ -1072,6 +1120,29 @@ describe("verification", () => {
).not.toBeDefined();
});
it("ignores cancelled verification requests", async () => {
// Given a verification request exists
const event = createVerificationRequestEvent();
returnRoomMessageFromSync(TEST_ROOM_ID, event);
// Wait for the request to be received
await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// When I cancel it
fetchMock.put("express:/_matrix/client/v3/rooms/:roomId/send/m.key.verification.cancel/:id", {
event_id: event.event_id,
});
await request!.cancel();
expect(request!.phase).toEqual(VerificationPhase.Cancelled);
// Then it is no longer found
expect(
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
).not.toBeDefined();
});
it("Plaintext verification request from Bob to Alice", async () => {
// Add verification request from Bob to Alice in the DM between them
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
@@ -672,7 +672,7 @@ describe("MatrixClient event timelines", function () {
expect(timeline!.getEvents().find((e) => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy();
});
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
it("should return null when event is not in the thread that the given timelineSet is representing", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
@@ -696,12 +696,12 @@ describe("MatrixClient event timelines", function () {
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(),
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeNull(),
httpBackend.flushAllExpected(),
]);
});
it("should return undefined when event is within a thread but timelineSet is not", () => {
it("should return null when event is within a thread but timelineSet is not", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
@@ -723,7 +723,7 @@ describe("MatrixClient event timelines", function () {
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(),
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeNull(),
httpBackend.flushAllExpected(),
]);
});
+1 -1
View File
@@ -159,7 +159,7 @@ describe("MatrixClient opts", function () {
await expect(
Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]),
).rejects.toThrow("MatrixError: [500] Unknown message");
).rejects.toThrow("MatrixError: [500] Ruh roh");
});
it("shouldn't queue events", async () => {
+7 -2
View File
@@ -63,7 +63,11 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
*
* @returns the sync response
*/
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
export function getSyncResponse(
roomMembers: string[],
roomId = TEST_ROOM_ID,
encryptStateEvents = false,
): ISyncResponse {
const roomResponse: IJoinedRoom = {
summary: {
"m.heroes": [],
@@ -77,7 +81,8 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
},
}),
],
+51 -10
View File
@@ -86,7 +86,9 @@ class MockWidgetApi extends EventEmitter {
? { event_id: `$${Math.random()}` }
: { delay_id: `id-${Math.random()}` },
);
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
public cancelScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public restartScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendToDevice = jest.fn().mockResolvedValue(undefined);
public requestOpenIDConnectToken = jest.fn(async () => {
return testOIDCToken;
@@ -531,17 +533,49 @@ describe("RoomWidgetClient", () => {
).rejects.toThrow();
});
it("updates delayed events", async () => {
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in parameter)",
async (action: UpdateDelayedEventAction) => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(
MatrixCapabilities.MSC4157UpdateDelayedEvent,
);
await client._unstable_updateDelayedEvent("id", action);
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
switch (action) {
case UpdateDelayedEventAction.Cancel:
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Restart:
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Send:
updateDelayedEvent = widgetApi.sendScheduledDelayedEvent;
break;
}
expect(updateDelayedEvent).toHaveBeenCalledWith("id");
},
);
it("can cancel scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
for (const action of [
UpdateDelayedEventAction.Cancel,
UpdateDelayedEventAction.Restart,
UpdateDelayedEventAction.Send,
]) {
await client._unstable_updateDelayedEvent("id", action);
expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action);
}
await client._unstable_cancelScheduledDelayedEvent("id");
expect(widgetApi.cancelScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
it("can restart scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
await client._unstable_restartScheduledDelayedEvent("id");
expect(widgetApi.restartScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
it("can send scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
await client._unstable_sendScheduledDelayedEvent("id");
expect(widgetApi.sendScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
});
@@ -583,6 +617,13 @@ describe("RoomWidgetClient", () => {
"Server does not support",
);
}
for (const updateDelayedEvent of [
client._unstable_cancelScheduledDelayedEvent,
client._unstable_restartScheduledDelayedEvent,
client._unstable_sendScheduledDelayedEvent,
]) {
await expect(updateDelayedEvent.call(client, "id")).rejects.toThrow("Server does not support");
}
});
});
});
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`] = `
{
+2 -2
View File
@@ -375,7 +375,7 @@ describe("FetchHttpApi", () => {
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
@@ -397,7 +397,7 @@ describe("FetchHttpApi", () => {
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
+31
View File
@@ -21,6 +21,8 @@ import {
ConnectionError,
HTTPError,
MatrixError,
MatrixSafetyError,
MatrixSafetyErrorCode,
parseErrorResponse,
retryNetworkOperation,
timeoutSignal,
@@ -185,6 +187,35 @@ describe("parseErrorResponse", () => {
),
);
});
it.each([
{
errcode: MatrixSafetyErrorCode.name,
error: "Spammy",
},
{
errcode: MatrixSafetyErrorCode.name,
error: "Spammy",
expiry: 5000,
},
{
errcode: MatrixSafetyErrorCode.name,
error: "Spammy",
harms: ["m.spam", "org.example.additional-harm"],
expiry: 5000,
},
])("should resolve MatrixSafetyErrors from fetch", (errContent) => {
headers.set("Content-Type", "application/json");
const value = parseErrorResponse(
{
headers,
status: 400,
} as Response,
JSON.stringify(errContent),
) as MatrixSafetyError;
expect(value).toBeInstanceOf(MatrixSafetyError);
expect(value.harms.size).toEqual(errContent.harms?.length ?? 0);
expect(value.expiry?.getTime()).toEqual(errContent.expiry);
});
describe("with HTTP headers", () => {
function addHeaders(headers: Headers) {
+247 -9
View File
@@ -84,6 +84,7 @@ import { mockOpenIdConfiguration } from "../test-utils/oidc.ts";
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { SyncResponder } from "../test-utils/SyncResponder.ts";
import { mockInitialApiRequests } from "../test-utils/mockEndpoints.ts";
import { type Transport } from "src/matrixrtc/index.ts";
jest.useFakeTimers();
@@ -801,6 +802,10 @@ describe("MatrixClient", function () {
await expect(
client._unstable_updateDelayedEvent("anyDelayId", UpdateDelayedEventAction.Send),
).rejects.toThrow(errorMessage);
await expect(client._unstable_cancelScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
await expect(client._unstable_restartScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
await expect(client._unstable_sendScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
});
it("works with null threadId", async () => {
@@ -1053,34 +1058,193 @@ describe("MatrixClient", function () {
);
});
it("can look up delayed events", async () => {
describe("lookups", () => {
const statuses = [undefined, "scheduled" as const, "finalised" as const];
const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]];
const inputs = statuses.flatMap((status) =>
delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]),
);
it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => {
httpLookups = [
{
method: "GET",
prefix: unstableMSC4140Prefix,
path: "/delayed_events",
expectQueryParams: {
status,
delay_id: delayId,
},
data: [],
},
];
await client._unstable_getDelayedEvents(status, delayId);
});
});
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in request body)",
async (action: UpdateDelayedEventAction) => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
},
);
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in request body fallback when auth required)",
async (action: UpdateDelayedEventAction) => {
const delayId = "id";
const baseLookup = {
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
};
httpLookups = [
{
...baseLookup,
error: {
httpStatus: 401,
errcode: "M_MISSING_TOKEN",
},
},
{
...baseLookup,
data: {
action,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
},
);
it("can cancel scheduled delayed events (action in request path)", async () => {
const delayId = "id";
httpLookups = [
{
method: "GET",
method: "POST",
prefix: unstableMSC4140Prefix,
path: "/delayed_events",
data: [],
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
},
];
await client._unstable_getDelayedEvents();
await client._unstable_cancelScheduledDelayedEvent(delayId);
});
it("can update delayed events", async () => {
it("can restart scheduled delayed events (action in request path)", async () => {
const delayId = "id";
const action = UpdateDelayedEventAction.Restart;
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
},
];
await client._unstable_restartScheduledDelayedEvent(delayId);
});
it("can send scheduled delayed events (action in request path)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
},
];
await client._unstable_sendScheduledDelayedEvent(delayId);
});
it("can cancel scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action,
action: UpdateDelayedEventAction.Cancel,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
await client._unstable_cancelScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
it("can restart scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action: UpdateDelayedEventAction.Restart,
},
},
];
await client._unstable_restartScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
it("can send scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action: UpdateDelayedEventAction.Send,
},
},
];
await client._unstable_sendScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
});
@@ -2366,6 +2530,61 @@ describe("MatrixClient", function () {
});
});
describe("disableVoip option", () => {
const baseUrl = "https://alice-server.com";
const userId = "@alice:bar";
const accessToken = "sometoken";
beforeEach(() => {
mocked(supportsMatrixCall).mockReturnValue(true);
});
afterAll(() => {
mocked(supportsMatrixCall).mockReset();
});
it("should not call /voip/turnServer when disableVoip = true", () => {
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, 200);
const client = createClient({
baseUrl,
accessToken,
userId,
disableVoip: true,
});
// Only check createCall / supportsVoip, avoid startClient
expect(client.createCall("!roomId:example.com")).toBeNull();
expect(client.supportsVoip?.()).toBe(false);
});
it("should call /voip/turnServer when disableVoip is not set", () => {
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, {
uris: ["turn:turn.example.org"],
});
createClient({
baseUrl,
accessToken,
userId,
});
// The call will trigger the request if VoIP is supported
expect(fetchMock.called(`${baseUrl}/_matrix/client/unstable/voip/turnServer`)).toBe(false);
});
it("should return null from createCall when disableVoip = true", () => {
const client = createClient({
baseUrl,
accessToken,
userId,
disableVoip: true,
});
expect(client.createCall("!roomId:example.com")).toBeNull();
});
});
describe("support for ignoring invites", () => {
beforeEach(() => {
// Mockup `getAccountData`/`setAccountData`.
@@ -3717,4 +3936,23 @@ describe("MatrixClient", function () {
expect(lookupResult[0]).toEqual({ address: "bob@email.dummy", mxid: "@bob:homeserver.dummy" });
});
});
describe("_unstable_getRTCTransports", () => {
it("makes a well-formed request", async () => {
httpLookups = [
{
method: "GET",
path: `/rtc/transports`,
data: { rtc_transports: [{ type: "livekit", extra_field: "foobar" }] satisfies Transport[] },
prefix: "/_matrix/client/unstable/org.matrix.msc4143",
},
];
expect(await client._unstable_getRTCTransports()).toEqual([
{
type: "livekit",
extra_field: "foobar",
},
]);
});
});
});
+289 -19
View File
@@ -19,6 +19,7 @@ import {
CallMembership,
type SessionMembershipData,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks";
@@ -26,9 +27,17 @@ function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: jest.fn().mockReturnValue(originTs),
getSender: jest.fn().mockReturnValue("@alice:example.org"),
getId: jest.fn().mockReturnValue("$eventid"),
getContent: jest.fn().mockReturnValue({}),
} as unknown as MatrixEvent;
}
function createCallMembership(ev: MatrixEvent, content: unknown): CallMembership {
(ev.getContent as jest.Mock).mockReturnValue(content);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, "xx");
}
describe("CallMembership", () => {
describe("SessionMembershipData", () => {
beforeEach(() => {
@@ -40,39 +49,40 @@ describe("CallMembership", () => {
});
const membershipTemplate: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
focus_active: { type: "livekit" },
foci_preferred: [{ type: "livekit" }],
"call_id": "",
"scope": "m.room",
"application": "m.call",
"device_id": "AAAAAAA",
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
"foci_preferred": [{ type: "livekit" }],
"m.call.intent": "voice",
};
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
const membership = createCallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
const membership = createCallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
@@ -82,23 +92,283 @@ describe("CallMembership", () => {
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1));
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
});
it("considers memberships expired if local age large enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1));
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
});
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
const membership = createCallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
expect(membership.transports).toEqual([mockFocus]);
});
describe("getTransport", () => {
const mockFocus = { type: "this_is_a_mock_focus" };
const oldestMembership = createCallMembership(makeMockEvent(), membershipTemplate);
it("gets the correct active transport with oldest_membership", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we use its focus.
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
});
it("gets the correct active transport with multi_sfu", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we still use our own focus in multi sfu.
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
});
it("does not provide focus if the selection method is unknown", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "unknown" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toBeUndefined();
});
});
describe("correct values from computed fields", () => {
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
});
it("returns correct scope", () => {
expect(membership.scope).toBe("m.room");
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("@alice:example.org:AAAAAAA");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
expect(membership.isExpired()).toBe(true);
});
});
});
describe("RtcMembershipData", () => {
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
};
it("rejects membership with no slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.toThrow();
});
it("rejects membership with no application", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
}).toThrow();
});
it("rejects membership with incorrect application", () => {
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: { wrong_type_key: "unknown" },
});
}).toThrow();
});
it("rejects membership with no member", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
}).toThrow();
});
it("rejects membership with incorrect member", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@@test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
});
}).toThrow();
});
it("rejects membership with incorrect sticky_key", () => {
expect(() => {
createCallMembership(makeMockEvent(), membershipTemplate);
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: 1,
msc4354_sticky_key: undefined,
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: "1",
msc4354_sticky_key: undefined,
});
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: 1,
sticky_key: "valid",
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid",
sticky_key: "valid",
});
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid_but_different",
sticky_key: "valid",
});
}).toThrow();
});
it("considers memberships unexpired if local age low enough", () => {
// TODO link prev event
});
it("considers memberships expired if local age large enough", () => {
// TODO link prev event
});
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
const oldestMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
rtc_transports: [{ type: "oldest_transport" }],
});
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
});
});
describe("correct values from computed fields", () => {
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({
"type": "m.call",
"m.call.id": "",
"m.call.intent": "voice",
});
});
it("returns correct scope", () => {
expect(membership.scope).toBe(undefined);
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("xyzHASHxyz");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(undefined);
expect(membership.getMsUntilExpiry()).toBe(undefined);
expect(membership.isExpired()).toBe(false);
});
});
});
@@ -109,7 +379,7 @@ describe("CallMembership", () => {
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = new CallMembership(fakeEvent!, membershipTemplate);
membership = createCallMembership(fakeEvent!, membershipTemplate);
jest.useFakeTimers();
});
@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
import {
isLivekitTransport,
isLivekitFocusSelection,
isLivekitTransportConfig,
} from "../../../src/matrixrtc/LivekitTransport";
describe("LivekitFocus", () => {
it("isLivekitFocus", () => {
expect(
isLivekitFocus({
isLivekitTransport({
type: "livekit",
livekit_service_url: "http://test.com",
livekit_alias: "test",
}),
).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
expect(
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
).toBeFalsy();
});
it("isLivekitFocusActive", () => {
expect(
isLivekitFocusActive({
isLivekitFocusSelection({
type: "livekit",
focus_selection: "oldest_membership",
}),
).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
});
it("isLivekitFocusConfig", () => {
expect(
isLivekitFocusConfig({
isLivekitTransportConfig({
type: "livekit",
livekit_service_url: "http://test.com",
}),
).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
});
});
File diff suppressed because it is too large Load Diff
@@ -14,136 +14,149 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc";
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
import { logger } from "../../../src/logger";
import { flushPromises } from "../../test-utils/flushPromises";
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
"MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
beforeEach(() => {
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
});
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
});
it("Fires event when session starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
if (eventKind === "memberState") {
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
} else {
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
await flushPromises();
}
});
it("Doesn't fire event if unrelated sessions starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
beforeEach(() => {
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
});
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
});
it("Fires event when session starts", async () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Doesn't fire event if unrelated sessions starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Fires event when session ends", async () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
await flushPromises();
await sendLeaveMembership(room1, membershipData);
it("Fires event when session ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
client.emit(ClientEvent.Room, room1);
it("Fires correctly with custom sessionDescription", async () => {
const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
const sessionManager = new MatrixRTCSessionManager(logger, client, {
id: "test",
application: "m.notCall",
});
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
// manually start the session manager (its not the default one started by the client)
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
try {
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
const room2MembershipData: MembershipData[] = [
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
it("Fires correctly with for with custom sessionDescription", () => {
const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
// Stop room1's RTC session. Tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// manually start the session manager (its not the default one started by the client)
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
// Stop room1's RTC session. Not tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
it("Doesn't fire event if unrelated sessions ends", async () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
const room1 = makeMockRoom(membership, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
jest.spyOn(client, "getRoom").mockReturnValue(room2);
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
client.emit(ClientEvent.Room, room1);
it("Doesn't fire event if unrelated sessions ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, membership);
client.emit(ClientEvent.Room, room1);
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
});
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
},
);
+202 -136
View File
@@ -23,17 +23,17 @@ import {
MatrixError,
UnsupportedDelayedEventsEndpointError,
type Room,
MAX_STICKY_DURATION_MS,
} from "../../../src";
import {
MembershipManagerEvent,
Status,
type Focus,
type LivekitFocusActive,
type Transport,
type SessionMembershipData,
type LivekitFocusSelection,
} from "../../../src/matrixrtc";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { logger } from "../../../src/logger.ts";
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
/**
* Create a promise that will resolve once a mocked method is called.
@@ -76,11 +76,11 @@ const callSession = { id: "", application: "m.call" };
describe("MembershipManager", () => {
let client: MockClient;
let room: Room;
const focusActive: LivekitFocusActive = {
const focusActive: LivekitFocusSelection = {
focus_selection: "oldest_membership",
type: "livekit",
};
const focus: Focus = {
const focus: Transport = {
type: "livekit",
livekit_service_url: "https://active.url",
livekit_alias: "!active:active.url",
@@ -94,7 +94,12 @@ describe("MembershipManager", () => {
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_cancelScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client.sendStateEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
});
afterEach(() => {
@@ -104,12 +109,12 @@ describe("MembershipManager", () => {
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -120,11 +125,13 @@ describe("MembershipManager", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
// Test
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
memberManager.join([focus], focusActive);
const memberManager = new MembershipManager(undefined, room, client, callSession);
memberManager.join([focus], undefined);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
@@ -136,12 +143,13 @@ describe("MembershipManager", () => {
device_id: "AAAAAAA",
expires: 14400000,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
restartScheduledDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
@@ -153,15 +161,15 @@ describe("MembershipManager", () => {
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
const memberManager = new MembershipManager(undefined, room, client, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
const waitForRestartScheduledDelayedEvent = waitForMockCallOnce(
client._unstable_restartScheduledDelayedEvent,
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
);
memberManager.join([focus], focusActive);
await waitForSendState;
await waitForUpdateDelaye;
await waitForRestartScheduledDelayedEvent;
await jest.advanceTimersByTimeAsync(1);
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
@@ -177,7 +185,7 @@ describe("MembershipManager", () => {
if (useOwnedStateEvents) {
room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
}
const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent);
const restartScheduledDelayedEvent = waitForMockCall(client._unstable_restartScheduledDelayedEvent);
const sentDelayedState = waitForMockCall(
client._unstable_sendDelayedStateEvent,
Promise.resolve({
@@ -228,10 +236,9 @@ describe("MembershipManager", () => {
},
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
manager.join([focus]);
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
await sendDelayedStateAttempt;
@@ -257,6 +264,7 @@ describe("MembershipManager", () => {
expires: 14400000,
device_id: "AAAAAAA",
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
} satisfies SessionMembershipData,
userStateKey,
@@ -264,13 +272,13 @@ describe("MembershipManager", () => {
await sentDelayedState;
// should have prepared the heartbeat to keep delaying the leave event while still connected
await updatedDelayedEvent;
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
await restartScheduledDelayedEvent;
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
await jest.advanceTimersByTimeAsync(5000);
// should update delayed disconnect
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
}
it("sends a membership event after rate limits during delayed event setup when joining a call", async () => {
@@ -286,8 +294,8 @@ describe("MembershipManager", () => {
describe("delayed leave event", () => {
it("does not try again to schedule a delayed leave event if not supported", () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
@@ -298,21 +306,15 @@ describe("MembershipManager", () => {
});
it("does try to schedule a delayed leave event again if rate limited", async () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 123456 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
manager.join([focus]);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 123456 },
@@ -329,11 +331,11 @@ describe("MembershipManager", () => {
{ delayedLeaveEventRestartMs: RESTART_DELAY },
room,
client,
() => undefined,
callSession,
);
// Join with the membership manager
manager.join([focus], focusActive);
manager.join([focus]);
expect(manager.status).toBe(Status.Connecting);
// Let the scheduler run one iteration so that we can send the join state event
await jest.runOnlyPendingTimersAsync();
@@ -348,7 +350,7 @@ describe("MembershipManager", () => {
// (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(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
new MatrixError({ errcode: "M_NOT_FOUND" }),
);
@@ -367,11 +369,11 @@ describe("MembershipManager", () => {
{ membershipEventExpiryMs: 1234567 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
manager.join([focus]);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
@@ -383,6 +385,7 @@ describe("MembershipManager", () => {
device_id: "AAAAAAA",
expires: 1234567,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: {
focus_selection: "oldest_membership",
type: "livekit",
@@ -393,11 +396,11 @@ describe("MembershipManager", () => {
});
it("does nothing if join called when already joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
manager.join([focus], focusActive);
manager.join([focus]);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
@@ -405,21 +408,21 @@ describe("MembershipManager", () => {
describe("leave()", () => {
// TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave();
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
@@ -428,74 +431,36 @@ describe("MembershipManager", () => {
);
});
it("does nothing if not joined", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
// After joining we want our own focus to be the one we select.
getOldestMembership.mockReturnValue(
mockCallMembership(
{
...membershipTemplate,
foci_preferred: [
{
livekit_alias: "!active:active.url",
livekit_service_url: "https://active.url",
type: "livekit",
},
],
user_id: client.getUserId()!,
device_id: client.getDeviceId()!,
created_ts: 1000,
},
room.roomId,
),
);
expect(manager.getActiveFocus()).toStrictEqual(focus);
getOldestMembership.mockReturnValue(
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
room.roomId,
),
);
// If there is an older member we use its focus.
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
});
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_cancelScheduledDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
await manager.onRTCSessionMemberUpdate([
@@ -511,14 +476,17 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
// Our own membership is removed:
@@ -527,19 +495,19 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
});
it("updates the UpdateExpiry entry in the action scheduler", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
new MatrixError({ errcode: "M_NOT_FOUND" }),
);
@@ -552,7 +520,7 @@ describe("MembershipManager", () => {
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
expect(manager.status).toBe(Status.Connected);
});
});
@@ -564,7 +532,6 @@ describe("MembershipManager", () => {
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
@@ -573,17 +540,17 @@ describe("MembershipManager", () => {
// The first call is from checking id the server deleted the delayed event
// so it does not need a `advanceTimersByTime`
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
for (let i = 2; i <= 12; i++) {
// flush promises before advancing the timers to make sure schedulers are setup
await jest.advanceTimersByTimeAsync(10_000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(i);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
}
});
@@ -596,7 +563,7 @@ describe("MembershipManager", () => {
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
@@ -621,14 +588,14 @@ describe("MembershipManager", () => {
describe("status updates", () => {
it("starts 'Disconnected'", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.status).toBe(Status.Disconnected);
});
it("emits 'Connection' and 'Connected' after join", async () => {
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -642,7 +609,7 @@ describe("MembershipManager", () => {
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
});
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -658,7 +625,7 @@ describe("MembershipManager", () => {
it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -685,7 +652,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
@@ -705,7 +672,7 @@ describe("MembershipManager", () => {
it("abandons retry loop if leave() was called before sending state event", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -731,7 +698,7 @@ describe("MembershipManager", () => {
});
describe("retries sending update delayed leave event restart", () => {
it("resends the initial check delayed update event", async () => {
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -740,22 +707,22 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
// Hit rate limit
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// Hit second rate limit.
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
// Setup resolve
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
@@ -773,7 +740,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "2" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -784,7 +751,7 @@ describe("MembershipManager", () => {
// 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(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -793,7 +760,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -804,7 +771,7 @@ describe("MembershipManager", () => {
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
@@ -817,7 +784,6 @@ describe("MembershipManager", () => {
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive, unrecoverableError);
@@ -836,7 +802,7 @@ describe("MembershipManager", () => {
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -850,7 +816,7 @@ describe("MembershipManager", () => {
{ delayedLeaveEventDelayMs: 10000 },
room,
client,
() => undefined,
callSession,
);
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
@@ -859,11 +825,11 @@ describe("MembershipManager", () => {
manager.join([focus], focusActive);
try {
// Let the scheduler run one iteration so that we can send the join state event
await waitForMockCall(client._unstable_updateDelayedEvent);
await waitForMockCall(client._unstable_restartScheduledDelayedEvent);
// We never resolve the delayed event so that we can test the probablyLeft event.
// This simulates the case where the server does not respond to the delayed event.
client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise);
client._unstable_restartScheduledDelayedEvent = jest.fn(() => stuckPromise);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
@@ -873,18 +839,18 @@ describe("MembershipManager", () => {
await jest.advanceTimersByTimeAsync(5000);
// No emission after 5s
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(4999);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms.
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue({});
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue({});
// Emit after 10s
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(4);
expect(probablyLeftEmit).toHaveBeenCalledWith(true);
// Mock a sync which does not include our own membership
@@ -901,13 +867,113 @@ describe("MembershipManager", () => {
}
});
});
describe("updateCallIntent()", () => {
it("should fail if the user has not joined the call", async () => {
const manager = new MembershipManager({}, room, client, callSession);
// After joining we want our own focus to be the one we select.
try {
await manager.updateCallIntent("video");
throw Error("Should have thrown");
} catch {}
});
it("can adjust the intent", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
expect(eventContent["created_ts"]).toEqual(membership.createdTs());
expect(eventContent["m.call.intent"]).toEqual("video");
});
it("does nothing if the intent doesn't change", async () => {
const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
});
});
describe("StickyEventMembershipManager", () => {
beforeEach(() => {
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue(undefined);
});
describe("join()", () => {
describe("sends an rtc membership event", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
const memberManager = new StickyEventMembershipManager(
undefined,
room,
client,
callSession,
"@alice:example.org:AAAAAAA_m.call",
);
memberManager.join([], focus);
await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" }));
// Test we sent the initial join
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
room.roomId,
3600000,
null,
"org.matrix.msc4143.rtc.member",
{
application: { type: "m.call" },
member: {
user_id: "@alice:example.org",
id: "@alice:example.org:AAAAAAA_m.call",
device_id: "AAAAAAA",
},
slot_id: "m.call#",
rtc_transports: [focus],
versions: [],
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
);
restartScheduledDelayedEventHandle.resolve?.();
// Ensure we have sent the delayed disconnect event.
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
room.roomId,
MAX_STICKY_DURATION_MS,
{ delay: 8000 },
null,
"org.matrix.msc4143.rtc.member",
{
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
);
// ..once
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1);
});
});
});
});
});
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
const membershipManager = new MembershipManager(undefined, room, client, callSession);
const spy = jest.spyOn(console, "error");
// Double join
@@ -25,9 +25,9 @@ describe("OutdatedKeyFilter Test", () => {
const olderKey = fakeInboundSessionWithTimestamp(300);
// Simulate receiving out of order keys
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
expect(filter.isOutdated(aKey.membership, aKey)).toBe(false);
// Then we receive the most recent key out of order
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
const isOutdated = filter.isOutdated(aKey.membership, olderKey);
// this key is older and should be ignored even if received after
expect(isOutdated).toBe(true);
});
@@ -36,7 +36,7 @@ describe("OutdatedKeyFilter Test", () => {
return {
keyIndex: 0,
creationTS: ts,
participantId: "@alice:localhost|ABCDE",
membership: { userId: "@alice:localhost", deviceId: "ABDE", memberId: "@alice:localhost:ABCDE" },
key: new Uint8Array(16),
};
}
+285 -150
View File
@@ -22,10 +22,9 @@ import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTra
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
import { logger, type Logger } from "../../../src/logger.ts";
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
import { logger } from "../../../src/logger.ts";
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
describe("RTCEncryptionManager", () => {
// The manager being tested
@@ -34,6 +33,7 @@ describe("RTCEncryptionManager", () => {
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: jest.Mock;
let rtcIdentifierProvider: jest.Mock;
beforeEach(() => {
statistics = {
@@ -55,14 +55,18 @@ describe("RTCEncryptionManager", () => {
off: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
rtcIdentifierProvider = jest.fn().mockImplementation((userId: string, deviceId: string, memberId: string) => {
return `MOCKSHA<${userId}|${deviceId}|${memberId}>`;
});
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
);
});
@@ -78,13 +82,15 @@ describe("RTCEncryptionManager", () => {
});
describe("Sharing Keys", () => {
it("Set up my key asap even if no key distribution is needed", () => {
it("Set up my key asap even if no key distribution is needed", async () => {
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();
await flushPromises();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
});
@@ -92,14 +98,15 @@ describe("RTCEncryptionManager", () => {
it("Should distribute keys to members on join", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
@@ -114,16 +121,22 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "DEVICE01",
memberId: "@alice:example.org:DEVICE01",
userId: "@alice:example.org",
},
"@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)];
let members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE", 1000)];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
@@ -145,7 +158,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.sendKey.mockClear();
onEncryptionKeysChanged.mockClear();
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE", 2000)];
getMembershipMock.mockReturnValue(members);
// There are no membership change but the callMembership ts has changed (reset?)
@@ -172,8 +185,8 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
@@ -194,7 +207,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.sendKey.mockClear();
// Carl joins, within the grace period
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
members.push(aStateBaseMembership("@carl:example.org", "CARLDEVICE"));
await jest.advanceTimersByTimeAsync(gracePeriod / 2);
encryptionManager.onMembershipsUpdate();
@@ -222,8 +235,8 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
@@ -244,12 +257,12 @@ describe("RTCEncryptionManager", () => {
await jest.advanceTimersByTimeAsync(5 * 60 * 1000);
// A new member joins, that should trigger a key rotation.
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
members.push(aStateBaseMembership("@carl:example.org", "CARLDEVICE"));
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(1);
// A new member joins, within the grace period, but under the delay period
members.push(aCallMembership("@david:example.org", "DAVDEVICE"));
members.push(aStateBaseMembership("@david:example.org", "DAVDEVICE"));
await jest.advanceTimersByTimeAsync((useKeyDelay - gracePeriod) / 2);
encryptionManager.onMembershipsUpdate();
@@ -274,8 +287,8 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
@@ -289,7 +302,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.sendKey.mockClear();
await jest.advanceTimersByTimeAsync(gracePeriod + 1000);
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
members.push(aStateBaseMembership("@carl:example.org", "CARLDEVICE"));
encryptionManager.onMembershipsUpdate();
expect(mockTransport.sendKey).toHaveBeenCalledWith(
@@ -316,8 +329,8 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
@@ -330,11 +343,11 @@ describe("RTCEncryptionManager", () => {
mockTransport.sendKey.mockClear();
const newJoiners = [
aCallMembership("@carl:example.org", "CARLDEVICE"),
aCallMembership("@dave:example.org", "DAVEDEVICE"),
aCallMembership("@eve:example.org", "EVEDEVICE"),
aCallMembership("@frank:example.org", "FRANKDEVICE"),
aCallMembership("@george:example.org", "GEORGEDEVICE"),
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
aStateBaseMembership("@dave:example.org", "DAVEDEVICE"),
aStateBaseMembership("@eve:example.org", "EVEDEVICE"),
aStateBaseMembership("@frank:example.org", "FRANKDEVICE"),
aStateBaseMembership("@george:example.org", "GEORGEDEVICE"),
];
for (const newJoiner of newJoiners) {
@@ -366,8 +379,8 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
@@ -396,9 +409,9 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
@@ -419,8 +432,8 @@ describe("RTCEncryptionManager", () => {
onEncryptionKeysChanged.mockClear();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(updatedMembers);
@@ -443,6 +456,11 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
{
userId: "@alice:example.org",
deviceId: "DEVICE01",
memberId: "@alice:example.org:DEVICE01",
},
"@alice:example.org:DEVICE01",
);
@@ -452,9 +470,9 @@ describe("RTCEncryptionManager", () => {
it("Should not distribute keys if encryption is disabled", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
@@ -479,8 +497,7 @@ describe("RTCEncryptionManager", () => {
emit: emitter.emit.bind(emitter),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
@@ -491,7 +508,7 @@ describe("RTCEncryptionManager", () => {
it("should not accept keys when manageMediaKeys is disabled", async () => {
jest.useFakeTimers();
const members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
const members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE")];
getMembershipMock.mockReturnValue(members);
encryptionManager.join({ manageMediaKeys: false });
@@ -500,8 +517,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", memberId: "@bob:example.org:BOBDEVICE" },
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
@@ -515,9 +531,9 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE", 1000, "rtcIDBOB1"),
aCallMembership("@bob:example.org", "BOBDEVICE2", 1000, "rtcIDBOB2"),
aCallMembership("@carl:example.org", "CARLDEVICE", 1000, "rtcIDCARL1"),
];
getMembershipMock.mockReturnValue(members);
@@ -527,24 +543,21 @@ describe("RTCEncryptionManager", () => {
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", memberId: "@bob:example.org:BOBDEVICE" },
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE2",
{ userId: "@bob:example.org", deviceId: "BOBDEVICE2", memberId: "@bob:example.org:BOBDEVICE2" },
"BBBBBBBBBBB",
4 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", memberId: "@carl:example.org:CARLDEVICE" },
"CCCCCCCCCC",
8 /* KeyId */,
0 /* Timestamp */,
@@ -554,19 +567,34 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("AAAAAAAAAAA"),
0,
"@bob:example.org:BOBDEVICE",
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
memberId: "@bob:example.org:BOBDEVICE",
},
"rtcIDBOB1",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("BBBBBBBBBBB"),
4,
"@bob:example.org:BOBDEVICE2",
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE2",
memberId: "@bob:example.org:BOBDEVICE2",
},
"rtcIDBOB2",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("CCCCCCCCCC"),
8,
"@carl:example.org:CARLDEVICE",
{
userId: "@carl:example.org",
deviceId: "CARLDEVICE",
memberId: "@carl:example.org:CARLDEVICE",
},
"rtcIDCARL1",
);
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
@@ -575,7 +603,7 @@ describe("RTCEncryptionManager", () => {
it("Should support quick re-joiner if keys received out of order", async () => {
jest.useFakeTimers();
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
const members = [aStateBaseMembership("@carol:example.org", "CAROLDEVICE")];
getMembershipMock.mockReturnValue(members);
// Let's join
@@ -583,13 +611,13 @@ describe("RTCEncryptionManager", () => {
await jest.advanceTimersByTimeAsync(10);
// Simulate Carl leaving then joining back, and key received out of order
// XXX This can only happen in legacy because with sticky events the rtcBackendIdentity would be different.
const initialKey0TimeStamp = 1000;
const newKey0TimeStamp = 2000;
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
{ userId: "@carol:example.org", deviceId: "CAROLDEVICE", memberId: "@carol:example.org:CAROLDEVICE" },
"BBBBBBBBBBB",
0 /* KeyId */,
newKey0TimeStamp,
@@ -599,8 +627,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
{ userId: "@carol:example.org", deviceId: "CAROLDEVICE", memberId: "@carol:example.org:CAROLDEVICE" },
"AAAAAAAAAAA",
0 /* KeyId */,
initialKey0TimeStamp,
@@ -613,7 +640,12 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
decodeBase64("BBBBBBBBBBB"),
0,
"@carol:example.org:CAROLDEVICE",
{
userId: "@carol:example.org",
deviceId: "CAROLDEVICE",
memberId: "@carol:example.org:CAROLDEVICE",
},
"@carol:example.org|CAROLDEVICE",
);
});
@@ -621,9 +653,9 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE", 1000, "@bob:example.org|BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2", 1000, "@bob:example.org|BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE", 1000, "@carl:example.org|CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
@@ -635,8 +667,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", memberId: "@carl:example.org:CARLDEVICE" },
"BBBBBBBBBBB",
0 /* KeyId */,
1000,
@@ -644,8 +675,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", memberId: "@carl:example.org:CARLDEVICE" },
"CCCCCCCCCCC",
5 /* KeyId */,
1000,
@@ -653,8 +683,7 @@ describe("RTCEncryptionManager", () => {
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE2",
{ userId: "@bob:example.org", deviceId: "BOBDEVICE2", memberId: "@bob:example.org:BOBDEVICE2" },
"DDDDDDDDDDD",
0 /* KeyId */,
1000,
@@ -663,7 +692,13 @@ describe("RTCEncryptionManager", () => {
const knownKeys = encryptionManager.getEncryptionKeys();
// My own key should be there
const myRing = knownKeys.get(getParticipantId("@alice:example.org", "DEVICE01"));
const myRing = knownKeys.get(
getEncryptionKeyMapKey({
userId: "@alice:example.org",
deviceId: "DEVICE01",
memberId: "@alice:example.org:DEVICE01",
}),
);
expect(myRing).toBeDefined();
expect(myRing).toHaveLength(1);
expect(myRing![0]).toMatchObject(
@@ -673,7 +708,13 @@ describe("RTCEncryptionManager", () => {
}),
);
const carlRing = knownKeys.get(getParticipantId("@carl:example.org", "CARLDEVICE"));
const carlRing = knownKeys.get(
getEncryptionKeyMapKey({
userId: "@carl:example.org",
deviceId: "CARLDEVICE",
memberId: "@carl:example.org:CARLDEVICE",
}),
);
expect(carlRing).toBeDefined();
expect(carlRing).toHaveLength(2);
expect(carlRing![0]).toMatchObject(
@@ -689,7 +730,13 @@ describe("RTCEncryptionManager", () => {
}),
);
const bobRing = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE2"));
const bobRing = knownKeys.get(
getEncryptionKeyMapKey({
userId: "@bob:example.org",
deviceId: "BOBDEVICE2",
memberId: "@bob:example.org:BOBDEVICE2",
}),
);
expect(bobRing).toBeDefined();
expect(bobRing).toHaveLength(1);
expect(bobRing![0]).toMatchObject(
@@ -699,7 +746,13 @@ describe("RTCEncryptionManager", () => {
}),
);
const bob1Ring = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE"));
const bob1Ring = knownKeys.get(
getEncryptionKeyMapKey({
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
memberId: "@bob:example.org:BOBDEVICE",
}),
);
expect(bob1Ring).not.toBeDefined();
});
});
@@ -708,9 +761,9 @@ describe("RTCEncryptionManager", () => {
jest.useFakeTimers();
let members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
@@ -723,12 +776,20 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "DEVICE01",
memberId: "@alice:example.org:DEVICE01",
userId: "@alice:example.org",
},
"@alice:example.org:DEVICE01",
);
onEncryptionKeysChanged.mockClear();
// Trigger a key rotation with a leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
members = [
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// This should start a new key rollout
@@ -736,7 +797,7 @@ describe("RTCEncryptionManager", () => {
await jest.advanceTimersByTimeAsync(10);
// Now simulate a new leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE")];
getMembershipMock.mockReturnValue(members);
// The key `1` rollout is in progress
@@ -744,7 +805,7 @@ describe("RTCEncryptionManager", () => {
await jest.advanceTimersByTimeAsync(10);
// And another one ( plus a joiner)
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
const lastMembership = [aStateBaseMembership("@bob:example.org", "BOBDEVICE3")];
getMembershipMock.mockReturnValue(lastMembership);
// The key `1` rollout is still in progress
encryptionManager.onMembershipsUpdate();
@@ -759,11 +820,21 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
{
deviceId: "DEVICE01",
userId: "@alice:example.org",
memberId: "@alice:example.org:DEVICE01",
},
"@alice:example.org:DEVICE01",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
2,
{
deviceId: "DEVICE01",
memberId: "@alice:example.org:DEVICE01",
userId: "@alice:example.org",
},
"@alice:example.org:DEVICE01",
);
@@ -782,90 +853,153 @@ describe("RTCEncryptionManager", () => {
);
});
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>;
describe("RTC backend pseudonymous id", () => {
it("Should use pseudo rtcBackendIdentity if using sticky events", async () => {
getMembershipMock.mockReturnValue([]);
encryptionManager.join({
manageMediaKeys: true,
unstableSendStickyEvents: true,
});
encryptionManager.onMembershipsUpdate();
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>;
await flushPromises();
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
} as unknown as Mocked<Logger>;
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "DEVICE01",
userId: "@alice:example.org",
memberId: "@alice:example.org:DEVICE01",
},
"MOCKSHA<@alice:example.org|DEVICE01|@alice:example.org:DEVICE01>",
);
});
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
it("Should use legacy participant id if not using sticky event", async () => {
getMembershipMock.mockReturnValue([]);
encryptionManager.join({
manageMediaKeys: true,
unstableSendStickyEvents: false,
});
encryptionManager.onMembershipsUpdate();
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
transport,
statistics,
onEncryptionKeysChanged,
);
await flushPromises();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "DEVICE01",
userId: "@alice:example.org",
memberId: "@alice:example.org:DEVICE01",
},
"@alice:example.org:DEVICE01",
);
});
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate();
await jest.advanceTimersByTimeAsync(10);
it("Should use early keys as soon as the membership is known", async () => {
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>;
// Should have sent the key to the toDevice transport
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
encryptionManager = new RTCEncryptionManager(
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
);
// Simulate receiving a key by room transport
roomEmitter.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
getMembershipMock.mockReturnValue([]);
encryptionManager.join({
manageMediaKeys: true,
unstableSendStickyEvents: true,
});
encryptionManager.onMembershipsUpdate();
await flushPromises();
await jest.runOnlyPendingTimersAsync();
// In 2.0 mode the participant identity is pseudo hashed and known from
// the rtc membership itself. If a key is received before we have processed
// the membership, we cannot pass it to the media layer yet because we don't know
// the rtcBackendIdentity to use.
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", memberId: "@bob:example.org:BOBDEVICE" },
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
// The key should have been re-distributed to the room transport
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key re-distributed
0,
// to all the members
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
await flushPromises();
// No membership yet, cannot process the key, so should not have called the callback
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1 /* only own key */);
expect(onEncryptionKeysChanged).not.toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "BOBDEVICE",
userId: "@bob:example.org",
memberId: "@bob:example.org:BOBDEVICE",
},
expect.any(String),
);
// Now process membership
const bobRtcId = "MOCKSHA<@bob:example.org|BOBDEVICE|@bob:example.org:BOBDEVICE>";
const members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000, bobRtcId)];
getMembershipMock.mockReturnValue(members);
encryptionManager.onMembershipsUpdate();
await flushPromises();
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "BOBDEVICE",
userId: "@bob:example.org",
memberId: "@bob:example.org:BOBDEVICE",
},
bobRtcId,
);
});
});
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
function aCallMembership(
userId: string,
deviceId: string,
ts: number = 1000,
rtcBackendIdentity: string,
): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
rtcBackendIdentity,
);
}
/**
* Creates a basic state membership event for the given user and device.
* The rtcBackendIdentity is derived from userId and deviceId as `${userId}|${deviceId}`
* @param userId
* @param deviceId
* @param ts
*/
function aStateBaseMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
`${userId}|${deviceId}`,
);
}
});
@@ -882,6 +1016,7 @@ function expectKeyAtIndexToHaveBeenSentTo(
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
);
}
function expectKeyAtIndexNotToHaveBeenSentTo(
mockTransport: Mocked<ToDeviceKeyTransport>,
index: number,
@@ -1,187 +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 { type Mocked } from "jest-mock";
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 {
getMockClientWithEventEmitter,
mockClientMethodsEvents,
mockClientMethodsUser,
} from "../../test-utils/client.ts";
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";
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
describe("RoomAndToDeviceTransport", () => {
const roomId = "!room:id";
let mockClient: Mocked<MatrixClient>;
let statistics: Statistics;
let mockLogger: Mocked<Logger>;
let transport: RoomAndToDeviceTransport;
let mockRoom: Room;
let sendEventMock: jest.Mock;
let roomKeyTransport: RoomKeyTransport;
let toDeviceKeyTransport: ToDeviceKeyTransport;
let toDeviceSendKeySpy: jest.SpyInstance;
let roomSendKeySpy: jest.SpyInstance;
beforeEach(() => {
sendEventMock = jest.fn();
mockClient = getMockClientWithEventEmitter({
encryptAndSendToDevice: jest.fn(),
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
...mockClientMethodsEvents(),
...mockClientMethodsUser("@alice:example.org"),
sendEvent: sendEventMock,
});
mockRoom = makeMockRoom([]);
mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
getChild: jest.fn(),
} as unknown as Mocked<Logger>;
mockLogger.getChild.mockReturnValue(mockLogger);
statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
toDeviceKeyTransport = new ToDeviceKeyTransport(
"@alice:example.org",
"MYDEVICE",
mockRoom.roomId,
mockClient,
statistics,
);
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
});
it("should enable to device transport when starting", () => {
transport.start();
expect(transport.enabled.room).toBeFalsy();
expect(transport.enabled.toDevice).toBeTruthy();
});
it("only sends to device keys when sending a key", async () => {
transport.start();
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();
expect(transport.enabled.toDevice).toBeTruthy();
});
it("enables room transport and disables to device transport when receiving a room key", async () => {
transport.start();
const onNewKeyFromTransport = jest.fn();
const onTransportEnabled = jest.fn();
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
mockRoom.emit(
RoomEvent.Timeline,
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
undefined,
undefined,
false,
{} as IRoomTimelineData,
);
await jest.advanceTimersByTimeAsync(1);
expect(transport.enabled.room).toBeTruthy();
expect(transport.enabled.toDevice).toBeFalsy();
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();
const onTransportEnabled = jest.fn();
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
transport.setEnabled({ toDevice: false, room: false });
const dateNow = Date.now();
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
// for room key transport we will never get a disabled message because its will always just turn on
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
});
});
@@ -50,9 +50,15 @@ describe("ToDeviceKeyTransport", () => {
},
};
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
transport = new ToDeviceKeyTransport(
{ userId: "@alice:example.org", deviceId: "MYDEVICE", memberId: "@alice:example.org:MYDEVICE" },
roomId,
mockClient,
statistics,
{
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>,
);
});
it("should send my keys on via to device", async () => {
@@ -81,6 +87,7 @@ describe("ToDeviceKeyTransport", () => {
},
member: {
claimed_device_id: "MYDEVICE",
id: "@alice:example.org:MYDEVICE",
},
room_id: roomId,
session: {
@@ -102,8 +109,13 @@ describe("ToDeviceKeyTransport", () => {
keyBase64Encoded: string;
index: number;
}>();
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
transport.on(KeyTransportEvents.ReceivedKeys, (membership, keyBase64Encoded, index, _timestamp) => {
receivedKeyResolvers.resolve({
userId: membership.userId,
deviceId: membership.deviceId,
keyBase64Encoded,
index,
});
});
transport.start();
+59 -9
View File
@@ -15,13 +15,22 @@ limitations under the License.
*/
import { EventEmitter } from "stream";
import { type Mocked } from "jest-mock";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
import { secureRandomString } from "../../../src/randomstring";
import { type CallMembershipIdentityParts } from "src/matrixrtc/EncryptionManager";
import { logger } from "../../../src/logger.ts";
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
export const owmMemberIdentity: CallMembershipIdentityParts = {
deviceId: "AAAAAAA",
memberId: "@alice:example.org:AAAAAAA",
userId: "@alice:example.org",
};
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
application: "m.call",
call_id: "",
@@ -51,6 +60,11 @@ export type MockClient = Pick<
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
>;
/**
@@ -64,16 +78,23 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
sendStateEvent: jest.fn(),
cancelPendingEvent: jest.fn(),
_unstable_updateDelayedEvent: jest.fn(),
_unstable_cancelScheduledDelayedEvent: jest.fn(),
_unstable_restartScheduledDelayedEvent: jest.fn(),
_unstable_sendScheduledDelayedEvent: jest.fn(),
_unstable_sendDelayedStateEvent: jest.fn(),
_unstable_sendStickyEvent: jest.fn(),
_unstable_sendStickyDelayedEvent: jest.fn(),
};
}
export function makeMockRoom(
membershipData: MembershipData[],
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
useStickyEvents = false,
): Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }> {
const roomId = secureRandomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
const roomState = makeMockRoomState(membershipData, roomId);
const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId);
const ts = Date.now();
const room = Object.assign(new EventEmitter(), {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
@@ -81,11 +102,16 @@ export function makeMockRoom(
getState: jest.fn().mockReturnValue(roomState),
}),
getVersion: jest.fn().mockReturnValue("default"),
}) as unknown as Room;
_unstable_getStickyEvents: jest
.fn()
.mockImplementation(() =>
useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [],
) as any,
});
return Object.assign(room, {
emitTimelineEvent: (event: MatrixEvent) =>
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
});
}) as unknown as Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }>;
}
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
@@ -129,6 +155,7 @@ export function makeMockEvent(
roomId: string | undefined,
content: any,
timestamp?: number,
stateKey?: string,
): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(type),
@@ -137,16 +164,39 @@ export function makeMockEvent(
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
getRoomId: jest.fn().mockReturnValue(roomId),
getId: jest.fn().mockReturnValue(secureRandomString(8)),
getStateKey: jest.fn().mockReturnValue(stateKey),
isDecryptionFailure: jest.fn().mockReturnValue(false),
} as unknown as MatrixEvent;
}
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
export function mockRTCEvent(
{ user_id: sender, ...membershipData }: MembershipData,
roomId: string,
stickyDuration?: number,
timestamp?: number,
): MatrixEvent {
return {
...makeMockEvent(
stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
sender,
roomId,
membershipData,
timestamp,
!stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "",
),
unstableStickyExpiresAt: stickyDuration,
} as unknown as MatrixEvent;
}
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData);
export function mockCallMembership(
membershipData: MembershipData,
roomId: string,
rtcBackendIdentity?: string,
): CallMembership {
const ev = mockRTCEvent(membershipData, roomId);
(ev.getContent as jest.Mock).mockReturnValue(membershipData);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
}
export function makeKey(id: number, key: string): { key: string; index: number } {
+135
View File
@@ -0,0 +1,135 @@
/*
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 CallMembership } from "../../../src/matrixrtc";
import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types";
describe("types", () => {
describe("isMyMembership", () => {
it("returns false if userId is different", () => {
expect(
isMyMembership(
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
"@bob:example.org",
"DEVICE",
),
).toBe(false);
});
it("returns true if userId and device is the same", () => {
expect(
isMyMembership(
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
"@alice:example.org",
"DEVICE",
),
).toBe(true);
});
});
});
describe("IRTCNotificationContent", () => {
const validBase = Object.freeze({
"m.mentions": { user_ids: [], room: true },
"notification_type": "notification",
"sender_ts": 123,
"lifetime": 1000,
});
it("parses valid content", () => {
const res = parseCallNotificationContent({ ...validBase });
expect(res).toMatchObject(validBase);
});
it("caps lifetime to 90000ms", () => {
const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 });
expect(res.lifetime).toBe(90000);
});
it("throws on malformed m.mentions", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
"m.mentions": "not an object",
} as any),
).toThrow("malformed m.mentions");
});
it("throws on missing or invalid notification_type", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
notification_type: undefined,
} as any),
).toThrow("Missing or invalid notification_type");
expect(() =>
parseCallNotificationContent({
...validBase,
notification_type: 123 as any,
} as any),
).toThrow("Missing or invalid notification_type");
});
it("throws on missing or invalid sender_ts", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
sender_ts: undefined,
} as any),
).toThrow("Missing or invalid sender_ts");
expect(() =>
parseCallNotificationContent({
...validBase,
sender_ts: "123" as any,
} as any),
).toThrow("Missing or invalid sender_ts");
});
it("throws on missing or invalid lifetime", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
lifetime: undefined,
} as any),
).toThrow("Missing or invalid lifetime");
expect(() =>
parseCallNotificationContent({
...validBase,
lifetime: "1000" as any,
} as any),
).toThrow("Missing or invalid lifetime");
});
it("accepts valid relation (m.reference)", () => {
// Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`.
const res = parseCallNotificationContent({
...validBase,
relation: { rel_type: "m.reference", event_id: "$ev" },
} as any);
expect(res).toBeTruthy();
});
it("throws on invalid relation rel_type", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
relation: { rel_type: "m.annotation", event_id: "$ev" },
} as any),
).toThrow("Invalid relation");
});
});
+60 -1
View File
@@ -16,10 +16,11 @@ limitations under the License.
import { type MockedObject } from "jest-mock";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import {
type IAnnotatedPushRule,
type IStickyEvent,
type MatrixClient,
PushRuleActionName,
Room,
@@ -335,6 +336,31 @@ describe("MatrixEvent", () => {
}
});
describe("state key packing", () => {
it("should pack the state key during encryption", () => {
const ev = createStateEvent("$event1:server", "m.room.topic", "", { topic: "" });
expect(ev.getStateKey()).toStrictEqual("");
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(ev.getStateKey()).toStrictEqual("");
expect(ev.getWireStateKey()).toStrictEqual("m.room.topic:");
const keyedEv = createStateEvent("$event2:server", "m.beacon_info", "@alice:server", {});
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
keyedEv.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
expect(keyedEv.getWireStateKey()).toStrictEqual("m.beacon_info:@alice:server");
});
function createStateEvent(eventId: string, type: string, stateKey: string, content?: IContent): MatrixEvent {
return new MatrixEvent({
type,
state_key: stateKey,
content,
event_id: eventId,
});
}
});
describe("applyVisibilityEvent", () => {
it("should emit VisibilityChange if a change was made", async () => {
const ev = new MatrixEvent({
@@ -573,6 +599,39 @@ describe("MatrixEvent", () => {
expect(stateEvent.isState()).toBeTruthy();
expect(stateEvent.threadRootId).toBeUndefined();
});
it("should calculate sticky duration correctly", async () => {
const evData: IStickyEvent = {
event_id: "$event_id",
type: "some_state_event",
content: {},
sender: "@alice:example.org",
origin_server_ts: 50,
msc4354_sticky: {
duration_ms: 1000,
},
unsigned: {
msc4354_sticky_duration_ttl_ms: 5000,
},
};
try {
jest.useFakeTimers();
jest.setSystemTime(50);
// Prefer unsigned
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
// Fall back to `duration_ms`
expect(
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
).toEqual(1050);
// Prefer current time if `origin_server_ts` is more recent.
expect(
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
.unstableStickyExpiresAt,
).toEqual(1050);
} finally {
jest.useRealTimers();
}
});
});
function mainTimelineLiveEventIds(room: Room): Array<string> {
+4 -2
View File
@@ -127,15 +127,17 @@ describe("Poll", () => {
it("waits for existing relations request to finish when getting responses", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
// @ts-expect-error TS2769
const spy = jest.spyOn(poll, "fetchResponses");
const firstResponsePromise = poll.getResponses();
const secondResponsePromise = poll.getResponses();
await firstResponsePromise;
expect(firstResponsePromise).toEqual(secondResponsePromise);
await secondResponsePromise;
expect(spy).toHaveBeenCalledTimes(1);
expect(mockClient.relations).toHaveBeenCalledTimes(1);
});
it("filters relations for relevent response events", async () => {
it("filters relations for relevant response events", async () => {
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
+422
View File
@@ -0,0 +1,422 @@
import { type IStickyEvent, MatrixEvent } from "../../../src";
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";
describe("RoomStickyEvents", () => {
let stickyEvents: RoomStickyEventsStore;
const emitSpy: jest.Mock = jest.fn();
const stickyEvent: IStickyEvent = {
event_id: "$foo:bar",
room_id: "!roomId",
type: "org.example.any_type",
msc4354_sticky: {
duration_ms: 15000,
},
content: {
msc4354_sticky_key: "foobar",
},
sender: "@alice:example.org",
origin_server_ts: Date.now(),
unsigned: {},
};
beforeEach(() => {
emitSpy.mockReset();
stickyEvents = new RoomStickyEventsStore();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
});
afterEach(() => {
stickyEvents?.clear();
});
describe("addStickyEvents", () => {
it("should allow adding an event without a msc4354_sticky_key", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
});
it("should not allow adding an event without a msc4354_sticky property", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event without a sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event with an invalid sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should ignore old events", () => {
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should be able to just add an event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace events on ID tie break", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
event_id: "$abc:bar",
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace a newer event with an older event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
origin_server_ts: 1,
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should replace an older event with a newer event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$new",
origin_server_ts: Date.now() + 2000,
});
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([newerEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
});
it("should allow multiple events with the same sticky key for different event types", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
const anotherEv = new MatrixEvent({
...stickyEvent,
type: "org.example.another_type",
});
stickyEvents.addStickyEvents([originalEv, anotherEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
});
it("should emit when a new sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
it("should emit when a new unkeyed sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
});
describe("getStickyEvents", () => {
it("should have zero sticky events", () => {
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should contain a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
});
it("should contain two sticky events", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
const ev2 = new MatrixEvent({
...stickyEvent,
sender: "@fibble:bobble",
content: {
msc4354_sticky_key: "bibble",
},
});
stickyEvents.addStickyEvents([ev, ev2]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
});
});
describe("getKeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toBeUndefined();
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toEqual(ev);
});
});
describe("getUnkeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
content: {
msc4354_sticky_key: undefined,
},
});
stickyEvents.addStickyEvents([ev]);
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
});
});
describe("cleanExpiredStickyEvents", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should emit when a sticky event expires", () => {
jest.setSystemTime(1000);
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: 0,
});
const evLater = new MatrixEvent({
...stickyEvent,
event_id: "$baz:bar",
sender: "@bob:example.org",
origin_server_ts: 1000,
});
stickyEvents.addStickyEvents([ev, evLater]);
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
// Then expire the next event
jest.advanceTimersByTime(1000);
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
});
it("should emit two events when both expire at the same time", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev1 = new MatrixEvent({
...stickyEvent,
event_id: "$eventA",
origin_server_ts: 0,
});
const ev2 = new MatrixEvent({
...stickyEvent,
event_id: "$eventB",
content: {
msc4354_sticky_key: "key_2",
},
origin_server_ts: 0,
});
stickyEvents.addStickyEvents([ev1, ev2]);
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
});
it("should emit when a unkeyed sticky event expires", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
});
describe("handleRedaction", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should not emit if the event does not exist in the map", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction("$123456");
expect(emitSpy).not.toHaveBeenCalled();
});
it("should emit a remove when the event exists in the map without a predecessor", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(stickyEvent.event_id);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
it("should emit a remove when the event has no sticky key", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(stickyEvent.event_id);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
it("should emit an update when the event exists in the map with a predecessor", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
stickyEvents.addStickyEvents([ev, newerEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(newerEv.getId()!);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
});
it("should emit a remove if the previous event has expired", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
stickyEvents.addStickyEvents([ev, newerEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
// Expire the older event.
jest.advanceTimersByTime(stickyEvent.msc4354_sticky.duration_ms);
// Redact the newer event
stickyEvents.handleRedaction(newerEv.getId()!);
expect(emitSpy).toHaveBeenCalledWith([], [], [newerEv]);
});
it("should recurse the chain of events if the previous event has been redacted", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const middleEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
jest.advanceTimersByTime(1000);
const newestEv = new MatrixEvent({
...stickyEvent,
event_id: "$newest-ev",
origin_server_ts: Date.now() + 2000,
});
stickyEvents.addStickyEvents([ev, middleEv, newestEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
// Mark the middle event as redacted.
middleEv.setUnsigned({
redacted_because: {
event_id: "$foo",
} as any,
});
// Redact the newer event
stickyEvents.handleRedaction(newestEv.getId()!);
// expect immediate transition from newestEv -> ev and skipping middleEv
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
});
it("should revert to the most recent valid event regardless of insertion order", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const middleEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
jest.advanceTimersByTime(1000);
const newestEv = new MatrixEvent({
...stickyEvent,
event_id: "$newest-ev",
origin_server_ts: Date.now() + 2000,
});
// Invert in reverse order, to make sure we retain the older events.
stickyEvents.addStickyEvents([newestEv, middleEv, ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
// Mark the middle event as redacted.
middleEv.setUnsigned({
redacted_because: {
event_id: "$foo",
} as any,
});
// Redact the newer event
stickyEvents.handleRedaction(newestEv.getId()!);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
});
it("should handle redaction when using `handleRedaction` with a `MatrixEvent` parameter", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
stickyEvents.addStickyEvents([ev, newerEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(newerEv);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
});
});
});
+68 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
import { Direction, EventType, type MatrixClient, MatrixEvent, Room } from "../../../src";
import type { MockedObject } from "jest-mock";
const CREATOR_USER_ID = "@creator:example.org";
@@ -185,4 +185,71 @@ describe("Room", () => {
expectRedacted(messageEvents, room, false);
});
});
it("should ignore invalid m.room.name events", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const invalidNameEvent = new MatrixEvent({
type: EventType.RoomName,
content: {
name: { invalid: 123 },
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([invalidNameEvent]);
room.recalculate();
expect(room.name).toEqual("Empty room");
});
describe("getAltAliases()", () => {
it("should ignore invalid events", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const invalidAliasEvent = new MatrixEvent({
type: EventType.RoomCanonicalAlias,
content: {
alt_aliases: [123, "#foo:bar"],
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([invalidAliasEvent]);
room.recalculate();
expect(room.getAltAliases()).toEqual(["#foo:bar"]);
});
});
describe("calculateRoomName()", () => {
it("should ignore empty m.room.name 'name' field", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const event = new MatrixEvent({
type: EventType.RoomName,
content: {
name: "",
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([event]);
room.recalculate();
expect(room.name).not.toEqual("");
});
});
});
+37 -41
View File
@@ -21,7 +21,6 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
import { logger } from "../../../src/logger";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("OidcTokenRefresher", () => {
@@ -78,51 +77,49 @@ describe("OidcTokenRefresher", () => {
fetchMock.resetBehavior();
});
it("throws when oidc client cannot be initialised", async () => {
jest.spyOn(logger, "error");
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.oidcClientReady).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OIDC client.",
// error from OidcClient
expect.any(Error),
);
});
it("initialises oidc client", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// @ts-ignore peek at private property to see we initialised the client correctly
expect(refresher.oidcClient.settings).toEqual(
expect.objectContaining({
client_id: clientId,
redirect_uri: redirectUri,
authority: authConfig.issuer,
scope,
}),
);
});
describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => {
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
"Cannot get new token before OIDC client is initialised.",
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
});
it("should retry initialisation", async () => {
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
// put the successful mock back
fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config, { overwriteRoutes: true });
const result = await refresher.doRefreshAccessToken("token");
expect(result).toEqual(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
const result = await refresher.doRefreshAccessToken("refresh-token");
@@ -140,13 +137,12 @@ describe("OidcTokenRefresher", () => {
it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// spy on our stub
jest.spyOn(refresher, "persistTokens");
jest.spyOn(refresher as any, "persistTokens");
await refresher.doRefreshAccessToken("refresh-token");
expect(refresher.persistTokens).toHaveBeenCalledWith(
expect((refresher as any).persistTokens).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
+4 -4
View File
@@ -19,7 +19,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, type Optional, PollStartEvent } from "matrix-events-sdk";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise, type IMessageOpts } from "../test-utils/test-utils";
@@ -197,8 +197,8 @@ describe("Room", function () {
const addRoomThreads = (
room: Room,
thread1EventTs: Optional<number>,
thread2EventTs: Optional<number>,
thread1EventTs?: number,
thread2EventTs?: number,
): { thread1?: Thread; thread2?: Thread } => {
const result: { thread1?: Thread; thread2?: Thread } = {};
@@ -4159,7 +4159,7 @@ describe("Room", function () {
});
it("when there is only one thread, it should return this one", () => {
const { thread1 } = addRoomThreads(room, 23, null);
const { thread1 } = addRoomThreads(room, 23);
expect(room.getLastThread()).toBe(thread1);
});
@@ -92,9 +92,11 @@ describe("OutgoingRequestsManager", () => {
await secondRequest;
await thirdRequest;
// outgoingRequests should be called twice in total, as the second and third requests are
// processed in the same loop.
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
// outgoingRequests should be called three times in total:
// 1. the first time,
// 2. the second and third requests processed in the same loop, and
// 3. checking that all requests are finished
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked, type SpyInstance } from "jest-mock";
import { type Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "fetch-mock-jest";
@@ -211,14 +211,14 @@ describe("PerSessionKeyBackupDownloader", () => {
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA1`, mockCipherKey);
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const expectImported = expectSessionImported("!roomA", "sessionA1");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveLastReturnedWith(Promise.resolve({ ok: false, error: "MISSING_DECRYPTION_KEY" }));
await expect(spy.mock.results[0].value).rejects.toThrow("MISSING_DECRYPTION_KEY");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
await jest.runAllTimersAsync();
@@ -237,7 +237,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
@@ -297,7 +297,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
describe("Given no usable backup available", () => {
let getConfigSpy: SpyInstance;
let getConfigSpy: jest.SpyInstance;
beforeEach(async () => {
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
@@ -318,7 +318,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -336,7 +336,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -355,7 +355,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -377,7 +377,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -399,7 +399,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -488,7 +488,7 @@ describe("PerSessionKeyBackupDownloader", () => {
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const rateDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
@@ -542,7 +542,7 @@ describe("PerSessionKeyBackupDownloader", () => {
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const errorDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
@@ -606,7 +606,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
@@ -72,6 +72,7 @@ describe("RoomEncryptor", () => {
body: text,
msgtype: "m.text",
}),
isState: () => false,
makeEncrypted: jest.fn().mockReturnValue(undefined),
} as unknown as Mocked<MatrixEvent>;
}
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
{
+148
View File
@@ -830,6 +830,7 @@ describe("RustCrypto", () => {
TEST_DEVICE_ID,
secretStorage,
{} as CryptoCallbacks,
false,
);
async function createSecretStorageKey() {
@@ -853,9 +854,27 @@ describe("RustCrypto", () => {
});
});
it("getSecretStorageStatus", async () => {
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue("blah"),
isStored: jest.fn().mockResolvedValue({ blah: {} }),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
await expect(rustCrypto.getSecretStorageStatus()).resolves.toEqual({
defaultKeyId: "blah",
ready: true,
secretStorageKeyValidityMap: {
"m.cross_signing.master": true,
"m.cross_signing.self_signing": true,
"m.cross_signing.user_signing": true,
},
});
});
it("isSecretStorageReady", async () => {
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue(null),
isStored: jest.fn().mockResolvedValue(null),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
@@ -2418,6 +2437,135 @@ describe("RustCrypto", () => {
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
});
});
describe("Verification requests", () => {
it("fetches device details before room verification requests", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive a room verification request
const event = mockedEvent("!r:s.co", "@u:s.co", "m.room.message", "m.key.verification.request");
await rustCrypto.onLiveEventFromSync(event);
// Then we first fetch device details
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled();
// And we handle the verification event as normal
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
});
it("does not fetch device details before other verification events", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive some verification event that is not a room request
const event = mockedEvent("!r:s.co", "@u:s.co", "m.key.verification.start");
await rustCrypto.onLiveEventFromSync(event);
// Then we do not fetch device details
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
// And we handle the verification event as normal
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
});
it("throws an error if sender is missing", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive a verification event without a sender
// Then we throw
const event = mockedEvent("!r:s.co", null, "m.key.verification.start");
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
"missing sender in the event",
);
// And we do not fetch device details or handle the event
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
});
it("throws an error if room is missing", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive a verification event without a sender
// Then we throw
const event = mockedEvent(null, "@u:s.co", "m.key.verification.start");
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
"missing roomId in the event",
);
// And we do not fetch device details or handle the event
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
});
function mockedOlmMachine(): Mocked<OlmMachine> {
return {
queryKeysForUsers: jest.fn(),
getVerificationRequest: jest.fn(),
receiveVerificationEvent: jest.fn(),
} as unknown as Mocked<OlmMachine>;
}
function makeRustCrypto(
olmMachine: OlmMachine,
outgoingRequestProcessor: OutgoingRequestProcessor,
): RustCrypto {
const rustCrypto = new RustCrypto(
new DebugLogger(debug("test Verification requests")),
olmMachine,
{} as unknown as MatrixHttpApi<IHttpOpts & { onlyData: true }>,
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
// @ts-ignore mocking outgoingRequestProcessor
rustCrypto.outgoingRequestProcessor = outgoingRequestProcessor;
return rustCrypto;
}
function mockedOutgoingRequestProcessor(): OutgoingRequestProcessor {
return {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
}
function mockedEvent(
roomId: string | null,
senderId: string | null,
eventType: string,
msgtype?: string | undefined,
): MatrixEvent {
return {
isState: jest.fn().mockReturnValue(false),
getUnsigned: jest.fn().mockReturnValue({}),
isDecryptionFailure: jest.fn(),
isEncrypted: jest.fn(),
getType: jest.fn().mockReturnValue(eventType),
getRoomId: jest.fn().mockReturnValue(roomId),
getSender: jest.fn().mockReturnValue(senderId),
getId: jest.fn(),
getStateKey: jest.fn(),
getContent: jest.fn().mockReturnValue({ msgtype: msgtype }),
getTs: jest.fn(),
} as unknown as MatrixEvent;
}
});
});
/** Build a MatrixHttpApi instance */
+62
View File
@@ -26,6 +26,7 @@ import {
type ILeftRoom,
type IRoomEvent,
type IStateEvent,
type IStickyEvent,
type IStrippedState,
type ISyncResponse,
SyncAccumulator,
@@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () {
);
});
});
describe("MSC4354 sticky events", () => {
function stickyEvent(ts = 0): IStickyEvent {
const msgData = msg("test", "test text");
return {
...msgData,
msc4354_sticky: {
duration_ms: 1000,
},
origin_server_ts: ts,
};
}
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should accumulate sticky events", () => {
jest.setSystemTime(0);
const ev = stickyEvent();
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
});
it("should clear stale sticky events", () => {
jest.setSystemTime(1000);
const ev = stickyEvent(1000);
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
jest.setSystemTime(2000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
it("clears stale sticky events that pretend to be from the distant future", () => {
jest.setSystemTime(0);
const eventFarInTheFuture = stickyEvent(999999999999);
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
eventFarInTheFuture,
]);
jest.setSystemTime(1000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
});
});
function syncSkeleton(
+2 -2
View File
@@ -98,7 +98,7 @@ function createLinkedTimelines(): [EventTimeline, EventTimeline] {
describe("TimelineIndex", function () {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.getEventTimeline.mockResolvedValue(null);
});
describe("minIndex", function () {
@@ -193,7 +193,7 @@ describe("TimelineWindow", function () {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.getEventTimeline.mockResolvedValue(null);
mockClient.paginateEventTimeline.mockResolvedValue(false);
});
+5 -3
View File
@@ -187,12 +187,14 @@ describe("Group Call", function () {
});
it("does not start initializing local call feed twice", () => {
const promise1 = groupCall.initLocalCallFeed();
// @ts-expect-error TS2769
const spy = jest.spyOn(groupCall, "initLocalCallFeedInternal");
groupCall.initLocalCallFeed();
// @ts-ignore Mock
groupCall.state = GroupCallState.LocalCallFeedUninitialized;
const promise2 = groupCall.initLocalCallFeed();
groupCall.initLocalCallFeed();
expect(promise1).toEqual(promise2);
expect(spy).toHaveBeenCalledTimes(1);
});
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
+80 -6
View File
@@ -61,10 +61,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -77,7 +77,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
@@ -109,7 +109,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -122,10 +122,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -331,6 +331,80 @@ describe("Media Handler", function () {
expect(stream.getVideoTracks().length).toEqual(0);
});
it("falls back to ideal deviceId when exact deviceId fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.audio) {
const track = new MockMediaStreamTrack("audio_track", "audio");
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
const stream = await mediaHandler.getUserMediaStream(true, false);
// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
it("falls back to ideal deviceId for video when exact fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.video) {
const track = new MockMediaStreamTrack("video_track", "video");
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
const stream = await mediaHandler.getUserMediaStream(false, true);
// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
});
describe("getScreensharingStream", () => {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
{
+1 -2
View File
@@ -102,8 +102,7 @@ export interface IRoomMemberCountCondition extends IPushRuleCondition<ConditionK
is: AnyMemberCountCondition;
}
export interface ISenderNotificationPermissionCondition
extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
export interface ISenderNotificationPermissionCondition extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
key: string;
}
+4 -1
View File
@@ -15,9 +15,12 @@ limitations under the License.
*/
import type { ISignatures } from "./signed.ts";
import type { EventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
// Backwards compatible re-export
export type { EventDecryptionResult as IEventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
/** @deprecated This is an internal type and should not be used. */
type IEventDecryptionResult = EventDecryptionResult;
export type { IEventDecryptionResult };
interface Extensible {
[key: string]: any;
+4 -2
View File
@@ -58,7 +58,7 @@ import {
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
@@ -151,6 +151,7 @@ export enum EventType {
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
// MatrixRTC events
RTCMembership = "org.matrix.msc4143.rtc.member",
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline",
@@ -337,6 +338,7 @@ export interface TimelineEvents {
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
}
/**
@@ -369,7 +371,7 @@ export interface StateEvents {
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
+2 -5
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd, NamespacedValue, type Optional, UnstableValue } from "matrix-events-sdk";
import { type EitherAnd, NamespacedValue, UnstableValue } from "matrix-events-sdk";
import { isProvided } from "../extensible_events_v1/utilities.ts";
@@ -125,10 +125,7 @@ export type ExtensibleEventType = NamespacedValue<string, string> | string;
* @param expected - The expected event type.
* @returns True if the given type matches the expected type.
*/
export function isEventTypeSame(
given: Optional<ExtensibleEventType>,
expected: Optional<ExtensibleEventType>,
): boolean {
export function isEventTypeSame(given: ExtensibleEventType | null, expected: ExtensibleEventType | null): boolean {
if (typeof given === "string") {
if (typeof expected === "string") {
return expected === given;
+1 -1
View File
@@ -62,6 +62,6 @@ declare global {
interface Uint8ArrayConstructor {
// https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.frombase64
fromBase64?(base64: string, options?: Uint8ArrayFromBase64Options): Uint8Array;
fromBase64?(base64: string, options?: Uint8ArrayFromBase64Options): Uint8Array<ArrayBuffer>;
}
}
+37 -23
View File
@@ -20,6 +20,7 @@ import { type IEventWithRoomId, type SearchKey } from "./search.ts";
import { type IRoomEventFilter } from "../filter.ts";
import { type Direction } from "../models/event-timeline.ts";
import { type PushRuleAction } from "./PushRules.ts";
import { type MatrixError } from "../matrix.ts";
import { type IRoomEvent } from "../sync-accumulator.ts";
import { type EventType, type RelationType, type RoomType } from "./event.ts";
@@ -34,6 +35,7 @@ export interface IJoinRoomOpts {
/**
* The server names to try and join through in addition to those that are automatically chosen.
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
*/
viaServers?: string[];
@@ -71,6 +73,7 @@ export interface KnockRoomOpts {
/**
* The server names to try and knock through in addition to those that are automatically chosen.
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
*/
viaServers?: string | string[];
}
@@ -94,19 +97,20 @@ export interface ISendEventResponse {
event_id: string;
}
export type TimeoutDelay = {
delay: number;
};
export type ParentDelayId = {
parent_delay_id: string;
};
export type SendTimeoutDelayedEventRequestOpts = TimeoutDelay & Partial<ParentDelayId>;
export type SendActionDelayedEventRequestOpts = ParentDelayId;
export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts;
export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string };
export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts {
if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") {
// Invalid type, reject
return false;
}
if ("delay" in opts && typeof opts.delay !== "number") {
// Invalid type, reject.
return true;
}
// At least one of these fields must be specified.
return "delay" in opts || "parent_delay_id" in opts;
}
export type SendDelayedEventResponse = {
delay_id: string;
};
@@ -133,12 +137,22 @@ type DelayedPartialStateEvent = DelayedPartialTimelineEvent & {
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
export type DelayedEventInfoItem = DelayedPartialEvent &
SendDelayedEventResponse &
SendDelayedEventRequestOpts & {
running_since: number;
};
export type DelayedEventInfo = {
delayed_events: (DelayedPartialEvent &
SendDelayedEventResponse &
SendDelayedEventRequestOpts & {
running_since: number;
})[];
scheduled?: DelayedEventInfoItem[];
finalised?: {
delayed_event: DelayedEventInfoItem;
outcome: "send" | "cancel";
reason: "error" | "action" | "delay";
error?: MatrixError["data"];
event_id?: string;
origin_server_ts?: number;
}[];
next_batch?: string;
};
@@ -284,12 +298,12 @@ export interface IRelationsResponse {
}
export interface IContextResponse {
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
end?: string;
start?: string;
state?: IEventWithRoomId[];
events_before?: IEventWithRoomId[];
events_after?: IEventWithRoomId[];
event?: IEventWithRoomId;
}
export interface IEventsResponse {
+4 -3
View File
@@ -105,9 +105,10 @@ export interface RoomPinnedEventsEventContent {
}
export interface RoomEncryptionEventContent {
algorithm: "m.megolm.v1.aes-sha2";
rotation_period_ms?: number;
rotation_period_msgs?: number;
"algorithm": "m.megolm.v1.aes-sha2";
"io.element.msc4362.encrypt_state_events"?: boolean;
"rotation_period_ms"?: number;
"rotation_period_msgs"?: number;
}
export interface RoomHistoryVisibilityEventContent {
+1 -3
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
* is provided that the stable prefix should be used when representing the identifier.
@@ -62,7 +60,7 @@ export class NamespacedValue<S extends string, U extends string> {
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): Optional<T> {
public findIn<T>(obj: any): T | undefined {
let val: T | undefined = undefined;
if (this.name) {
val = obj?.[this.name];
+2 -2
View File
@@ -64,7 +64,7 @@ export function encodeUnpaddedBase64Url(uint8Array: Uint8Array): string {
return toBase64(uint8Array, { alphabet: "base64url", omitPadding: true });
}
function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8Array {
function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8Array<ArrayBuffer> {
if (typeof Uint8Array.fromBase64 === "function") {
// Currently this is only supported in Firefox,
// but we match the options in the hope in the future we can rely on it for all environments.
@@ -80,7 +80,7 @@ function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8
* @param base64 - The base64 to decode.
* @returns The decoded data.
*/
export function decodeBase64(base64: string): Uint8Array {
export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
// The function requires us to select an alphabet, but we don't know if base64url was used so we convert.
return fromBase64(base64.replace(/-/g, "+").replace(/_/g, "/"), { alphabet: "base64", lastChunkHandling: "loose" });
}
+539 -117
View File
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -90,8 +90,20 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*
* @param inviter - The user who invited us to the room and is expected to have
* sent the room key bundle.
*
* @returns `true` if the key bundle was successfuly downloaded and imported.
*/
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void>;
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean>;
/**
* Mark a room as pending a key bundle under MSC4268. The backend will listen for room key bundle messages, and if
* it sees one matching the room specified, it will automatically import it as long as the message author's ID matches
* the inviter's ID.
*
* @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite.
* @param inviterId - The user who invited us to the room and is expected to send the room key bundle.
*/
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): void;
}
/** The methods which crypto implementations should expose to the Sync api
@@ -188,8 +200,9 @@ export interface EventDecryptionResult {
*/
clearEvent: IClearEvent;
/**
* List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
* No longer used.
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
* @deprecated
*/
forwardingCurve25519KeyChain?: string[];
/**
@@ -200,11 +213,6 @@ export interface EventDecryptionResult {
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
*/
claimedEd25519Key?: string;
/**
* Whether the keys for this event have been received via an unauthenticated source (eg via key forwards, or
* restored from backup)
*/
untrusted?: boolean;
}
/**
+35 -1
View File
@@ -20,7 +20,7 @@ import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.t
import { type Room } from "../models/room.ts";
import { type DeviceMap } from "../models/device.ts";
import { type UIAuthCallback } from "../interactive-auth.ts";
import { type PassphraseInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
import { type PassphraseInfo, type SecretStorageKey, type SecretStorageKeyDescription } from "../secret-storage.ts";
import { type VerificationRequest } from "./verification.ts";
import {
type BackupTrustInfo,
@@ -118,6 +118,11 @@ export interface CryptoApi {
*/
isEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
/**
* Check if we believe the given room supports encrypted state events.
*/
isStateEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
@@ -364,6 +369,11 @@ export interface CryptoApi {
*/
isSecretStorageReady(): Promise<boolean>;
/**
* Inspect the status of secret storage, in more detail than {@link isSecretStorageReady}.
*/
getSecretStorageStatus(): Promise<SecretStorageStatus>;
/**
* Bootstrap [secret storage](https://spec.matrix.org/v1.12/client-server-api/#storage).
*
@@ -1143,6 +1153,30 @@ export interface CryptoCallbacks {
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
}
/**
* The result of a call to {@link CryptoApi.getSecretStorageStatus}.
*/
export interface SecretStorageStatus {
/** Whether secret storage is fully populated. The same as {@link CryptoApi.isSecretStorageReady}. */
ready: boolean;
/** The ID of the current default secret storage key. */
defaultKeyId: string | null;
/**
* For each secret that we checked whether it is correctly stored in secret storage with the default secret storage key.
*
* Note that we will only check that the key backup key is stored if key backup is currently enabled (i.e. that
* {@link CryptoApi.getActiveSessionBackupVersion} returns non-null). `m.megolm_backup.v1` will only be present in that case.
*
* (This is an object rather than a `Map` so that it JSON.stringify()s nicely, since its main purpose is to end up
* in logs.)
*/
secretStorageKeyValidityMap: {
[P in SecretStorageKey]?: boolean;
};
}
/**
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
*/
+4 -2
View File
@@ -20,8 +20,10 @@ import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
/**
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
*/
export interface VerificationRequest
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap> {
export interface VerificationRequest extends TypedEventEmitter<
VerificationRequestEvent,
VerificationRequestEventHandlerMap
> {
/**
* Unique ID for this verification request.
*
+67 -3
View File
@@ -37,7 +37,7 @@ import {
type ISendEventResponse,
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
type UpdateDelayedEventAction,
UpdateDelayedEventAction,
} from "./@types/requests.ts";
import { EventType, type StateEvents } from "./@types/event.ts";
import { logger } from "./logger.ts";
@@ -459,8 +459,12 @@ export class RoomWidgetClient extends MatrixClient {
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
* @deprecated Instead use one of:
* - {@link _unstable_cancelScheduledDelayedEvent}
* - {@link _unstable_restartScheduledDelayedEvent}
* - {@link _unstable_sendScheduledDelayedEvent}
*/
// eslint-disable-next-line
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
@@ -469,7 +473,67 @@ export class RoomWidgetClient extends MatrixClient {
);
}
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
switch (action) {
case UpdateDelayedEventAction.Cancel:
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Restart:
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Send:
updateDelayedEvent = this.widgetApi.sendScheduledDelayedEvent;
break;
}
await updateDelayedEvent.call(this.widgetApi, delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"cancelScheduledDelayedEvent",
);
}
await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"restartScheduledDelayedEvent",
);
}
await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendScheduledDelayedEvent",
);
}
await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
+22 -2
View File
@@ -54,14 +54,34 @@ export class ClientStoppedError extends Error {
}
/**
* This error is thrown when the Homeserver does not support the delayed events enpdpoints.
* This error is thrown when the Homeserver does not support the delayed events endpoints.
*/
export class UnsupportedDelayedEventsEndpointError extends Error {
public constructor(
message: string,
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
public clientEndpoint:
| "sendDelayedEvent"
| "updateDelayedEvent"
| "cancelScheduledDelayedEvent"
| "restartScheduledDelayedEvent"
| "sendScheduledDelayedEvent"
| "sendDelayedStateEvent"
| "getDelayedEvents",
) {
super(message);
this.name = "UnsupportedDelayedEventsEndpointError";
}
}
/**
* This error is thrown when the Homeserver does not support the sticky events endpoints.
*/
export class UnsupportedStickyEventsEndpointError extends Error {
public constructor(
message: string,
public clientEndpoint: "sendStickyEvent" | "sendStickyStateEvent",
) {
super(message);
this.name = "UnsupportedStickyEventsEndpointError";
}
}
+2 -4
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
import {
type ExtensibleEventType,
@@ -46,7 +44,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
/**
* The default HTML for the event, if provided.
*/
public readonly html: Optional<string>;
public readonly html?: string;
/**
* All the different renderings of the message. Note that this is the same
@@ -82,7 +80,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
this.renderings = mmessage;
} else if (isOptionalAString(mtext)) {
this.text = mtext;
this.html = mhtml;
this.html = mhtml ?? undefined;
this.renderings = [{ body: mtext, mimetype: "text/plain" }];
if (this.html) {
this.renderings.push({ body: this.html, mimetype: "text/html" });
+33
View File
@@ -18,6 +18,7 @@ import { type IMatrixApiError as IWidgetMatrixError } from "matrix-widget-api";
import { type IUsageLimit } from "../@types/partials.ts";
import { type MatrixEvent } from "../models/event.ts";
import { NamespacedValue } from "../NamespacedValue.ts";
interface IErrorJson extends Partial<IUsageLimit> {
[key: string]: any; // extensible
@@ -82,6 +83,8 @@ export class HTTPError extends Error {
export class MatrixError extends HTTPError {
// The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
public readonly errcode?: string;
// The Matrix 'error' value.
public readonly error?: string;
// The raw Matrix error JSON used to construct this object.
public data: IErrorJson;
@@ -108,6 +111,7 @@ export class MatrixError extends HTTPError {
}
super(`MatrixError: ${message}`, httpStatus, httpHeaders);
this.errcode = errorJson.errcode;
this.error = errorJson.error;
this.name = errorJson.errcode || "Unknown error code";
this.data = errorJson;
}
@@ -226,3 +230,32 @@ export class TokenRefreshLogoutError extends Error {
return "TokenRefreshLogoutError";
}
}
export const MatrixSafetyErrorCode = new NamespacedValue(null, "ORG.MATRIX.MSC4387_SAFETY");
/***
* This error is thrown when the homeserver refuses to handle an action due to a
* safety concern.
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387
*/
export class MatrixSafetyError extends MatrixError {
/**
* The kinds of harms detected by the server.
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387 for a list of spec defined harms.
*/
public readonly harms: Set<string>;
/**
* The date at which a request can be reattempted.
*/
public readonly expiry?: Date;
public constructor(...props: ConstructorParameters<typeof MatrixError>) {
super(...props);
const body = props[0];
this.harms = new Set(body && "harms" in body && Array.isArray(body.harms) ? body.harms : []);
this.message = `${super.message} (${[...this.harms].join(", ")})`;
if (body && "expiry" in body && typeof body.expiry === "number") {
this.expiry = new Date(body.expiry);
}
}
}
+8 -1
View File
@@ -279,6 +279,13 @@ export class FetchHttpApi<O extends IHttpOpts> {
const { signal, cleanup } = anySignal(signals);
// Set cache mode based on presence of Authorization header.
// Browsers/proxies do not cache responses to requests with Authorization headers.
// So specifying "no-cache" is redundant, and actually prevents caching
// of preflight requests in CORS scenarios. As such, we only set "no-cache"
// when there is no Authorization header.
const cacheMode = "Authorization" in headers ? undefined : "no-cache";
let res: Response;
const start = Date.now();
try {
@@ -291,7 +298,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
redirect: "follow",
referrer: "",
referrerPolicy: "no-referrer",
cache: "no-cache",
cache: cacheMode,
credentials: "omit", // we send credentials via headers
keepalive: keepAlive,
priority: opts.priority,
+2
View File
@@ -49,6 +49,8 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
* @param opts - options object
*
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
const includeFilename = opts.includeFilename ?? true;
+19 -2
View File
@@ -18,7 +18,14 @@ import { parse as parseContentType, type ParsedMediaType } from "content-type";
import { logger } from "../logger.ts";
import { sleep } from "../utils.ts";
import { ConnectionError, HTTPError, MatrixError, safeGetRetryAfterMs } from "./errors.ts";
import {
ConnectionError,
HTTPError,
MatrixError,
MatrixSafetyError,
MatrixSafetyErrorCode,
safeGetRetryAfterMs,
} from "./errors.ts";
// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
export function timeoutSignal(ms: number): AbortSignal {
@@ -92,8 +99,18 @@ export function parseErrorResponse(response: XMLHttpRequest | Response, body?: s
return <Error>e;
}
if (contentType?.type === "application/json" && body) {
const errorBody = JSON.parse(body);
if (errorBody.errcode && MatrixSafetyErrorCode.matches(errorBody.errcode)) {
return new MatrixSafetyError(
errorBody,
response.status,
isXhr(response) ? response.responseURL : response.url,
undefined,
httpHeaders,
);
}
return new MatrixError(
JSON.parse(body),
errorBody,
response.status,
isXhr(response) ? response.responseURL : response.url,
undefined,
+505 -72
View File
@@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixEvent } from "../matrix.ts";
import { MXID_PATTERN } from "../models/room-member.ts";
import { deepCompare } from "../utils.ts";
import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type SessionDescription } from "./MatrixRTCSession.ts";
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
import type { RTCCallIntent, Transport } from "./types.ts";
import { MatrixEvent, type IContent } from "../models/event.ts";
import { type RelationType } from "../@types/event.ts";
import { sha256 } from "../digest.ts";
import { encodeUnpaddedBase64Url } from "../base64.ts";
import { type Logger } from "../logger.ts";
/**
* The default duration in milliseconds that a membership is considered valid for.
@@ -28,16 +33,125 @@ import { type SessionDescription } from "./MatrixRTCSession.ts";
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
type CallScope = "m.room" | "m.user";
type Member = {
user_id: string;
device_id: string;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*/
id: string;
};
export interface RtcMembershipData {
"slot_id": string;
"member": Member;
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": {
type: string;
// other application specific keys
[key: string]: unknown;
};
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
const checkRtcMembershipData = (
data: IContent,
errors: string[],
referenceUserId: string,
): data is RtcMembershipData => {
const prefix = " - ";
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else {
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
return errors.length === 0;
};
/**
* MSC4143 (MatrixRTC) session membership data.
* Represents an entry in the memberships section of an m.call.member event as it is on the wire.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
application: string;
"application": string;
/**
* The id of this session.
@@ -45,23 +159,23 @@ export type SessionMembershipData = {
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
call_id: string;
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
device_id: string;
"device_id": string;
/**
* The focus selection system this user/membership is using.
*/
focus_active: Focus;
"focus_active": LivekitFocusSelection;
/**
* A list of possible foci this uses knows about. One of them might be used based on the focus_active
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
foci_preferred: Focus[];
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
@@ -70,138 +184,457 @@ export type SessionMembershipData = {
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
created_ts?: number;
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scroped call but multiple user owned calls (breakout sessions)
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
scope?: CallScope;
"scope"?: CallScope;
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
expires?: number;
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
*/
"msc4354_sticky_key"?: string;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
const prefix = "Malformed session membership event: ";
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
const prefix = " - ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
return errors.length === 0;
};
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
export class CallMembership {
public static equal(a: CallMembership, b: CallMembership): boolean {
return deepCompare(a.membershipData, b.membershipData);
public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a?.membershipData, b?.membershipData);
}
private membershipData: SessionMembershipData;
private logger?: Logger;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string; ts: number };
/** Anonymized identity to use with the RTC backend. */
public readonly rtcBackendIdentity: string;
/**
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
* @private
*/
private readonly membershipData: MembershipData;
public constructor(
private parentEvent: MatrixEvent,
data: any,
/** The required parts of the Matrix event that this membership is based on */
matrixEvent: Pick<MatrixEvent, "getId" | "getSender" | "getTs">,
/**
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
* It can be a IContent just for backwards compatibility.
* */
membershipData: MembershipData | IContent,
/**
*
* Anonymized identity to use with the RTC backend.
*
* The rtcBackendIdentity is optional only for backwards compatibility. If omitted, the constructor will
* fall back to the legacy `${sender}:${deviceId}` value.
* The rtcBackendIdentity is a hashed version of all the identity parts:
* `sha256(${this.userId}|${this.deviceId}|${this.memberId})`
*
* It is used to anonymize the identity of the user in the RTC backend.
*/
rtcBackendIdentity?: string,
/**
* The constructor will automatically create a properly tagged child logger instance.
*/
logger?: Logger,
) {
const sessionErrors: string[] = [];
if (!checkSessionsMembershipData(data, sessionErrors)) {
throw Error(
`unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`,
);
if (membershipData.kind === "rtc" || membershipData.kind === "session") {
this.membershipData = membershipData as MembershipData;
if (rtcBackendIdentity == undefined) {
throw new Error("rtcBackendIdentity must be defined when passing MembershipData");
}
this.rtcBackendIdentity = rtcBackendIdentity!;
} else {
this.membershipData = data;
// Backwards compatibility path for legacy code that passes raw content
this.membershipData = CallMembership.membershipDataFromMatrixEvent(
new MatrixEvent({
event_id: matrixEvent.getId(),
sender: matrixEvent.getSender(),
content: membershipData,
}),
);
this.rtcBackendIdentity = `${matrixEvent.getSender()}:${this.deviceId}`;
}
}
const [eventId, sender, ts] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getTs()];
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
public get sender(): string | undefined {
return this.parentEvent.getSender();
}
this.matrixEventData = { eventId, sender, ts };
public get eventId(): string | undefined {
return this.parentEvent.getId();
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
}
/**
* @deprecated Use sessionDescription.id instead.
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
* `${this.userId}:${this.deviceId}` for state events (kind = session)
*/
public get callId(): string {
return this.membershipData.call_id;
public static async computeRtcBackendIdentity(
matrixEvent: Pick<MatrixEvent, "getSender">,
membershipData: MembershipData,
): Promise<string> {
const { kind, data } = membershipData;
switch (kind) {
case "rtc": {
return CallMembership.computeRtcIdentityRaw(data.member.user_id, data.member.device_id, data.member.id);
}
case "session":
return `${matrixEvent.getSender()}:${data.device_id}`;
}
}
public static async computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
const hashInput = `${userId}|${deviceId}|${memberId}`;
const hashBuffer = await sha256(hashInput);
const hashedString = encodeUnpaddedBase64Url(hashBuffer);
return hashedString;
}
public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData {
const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()];
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
const sessionErrors: string[] = [];
const rtcErrors: string[] = [];
if (checkSessionsMembershipData(content, sessionErrors)) {
return { kind: "session", data: content };
} else if (checkRtcMembershipData(content, rtcErrors, sender)) {
return { kind: "rtc", data: content };
} else {
const details =
sessionErrors.length < rtcErrors.length
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
throw Error(`unknown CallMembership data.\n` + details + json);
}
}
/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.user_id;
case "session":
default:
return this.matrixEventData.sender;
}
}
public get eventId(): string {
return this.matrixEventData.eventId;
}
/**
* The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
* This is computed in case SessionMembershipData is used.
*/
public get slotId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.slot_id;
case "session":
default:
return slotDescriptionToId({ application: this.application, id: data.call_id });
}
}
public get deviceId(): string {
return this.membershipData.device_id;
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.device_id;
case "session":
default:
return data.device_id;
}
}
public get sessionDescription(): SessionDescription {
return {
application: this.membershipData.application,
id: this.membershipData.call_id,
};
public get callIntent(): RTCCallIntent | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc": {
const intent = data.application["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger?.warn("RTC membership has invalid m.call.intent");
return undefined;
}
case "session":
default:
return data["m.call.intent"];
}
}
public get application(): string | undefined {
return this.membershipData.application;
/**
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
*/
public get slotDescription(): SlotDescription {
return slotIdToDescription(this.slotId);
}
public get application(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application.type;
case "session":
default:
return data.application;
}
}
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application;
case "session":
default:
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
}
}
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
public get scope(): CallScope | undefined {
return this.membershipData.scope;
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
return data.scope;
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
public get membershipID(): string {
/**
* This computes the membership ID for the membership.
* For the sticky event based rtcSessionData this is trivial it is `member.id`.
* This is not supposed to be used to identity on an rtc backend. This is just a nouance for
* a generated (sha256) anonymised identity. Only send `rtcBackendIdentity` to any rtc backend service.
*
* For the legacy sessionMemberEvents it is a bit more complex. Here we sometimes do not have this data
* in the event content and we expected the SFU and the client to use `${this.matrixEventData.sender}:${data.device_id}`.
*
* So if there is no membershipID we use the hard coded jwt id default (`${this.matrixEventData.sender}:${data.device_id}`)
* value (used until version 0.16.0)
*
* It is also possible for a session event to set a custom membershipID. in that case this will be used.
*/
public get memberId(): string {
// the createdTs behaves equivalent to the membershipID.
// we only need the field for the legacy member envents where we needed to update them
// we only need the field for the legacy member events where we needed to update them
// synapse ignores sending state events if they have the same content.
return this.createdTs().toString();
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.id;
case "session":
default:
return (
// best case we have a client already publishing the right custom membershipId
data.membershipID ??
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
`${this.matrixEventData.sender}:${data.device_id}`
);
}
}
public createdTs(): number {
return this.membershipData.created_ts ?? this.parentEvent.getTs();
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEventData.ts;
case "session":
default:
return data.created_ts ?? this.matrixEventData.ts;
}
}
/**
* Gets the absolute expiry timestamp of the membership.
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
*/
public getAbsoluteExpiry(): number {
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
public getAbsoluteExpiry(): number | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
}
}
/**
* @returns The number of milliseconds until the membership expires or undefined if applicable
*/
public getMsUntilExpiry(): number {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry() - Date.now();
public getMsUntilExpiry(): number | undefined {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry()! - Date.now();
}
}
/**
* @returns true if the membership has expired, otherwise false
*/
public isExpired(): boolean {
return this.getMsUntilExpiry() <= 0;
}
public getPreferredFoci(): Focus[] {
return this.membershipData.foci_preferred;
}
public getFocusSelection(): string | undefined {
const focusActive = this.membershipData.focus_active;
if (isLivekitFocusActive(focusActive)) {
return focusActive.focus_selection;
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return false;
case "session":
default:
return this.getMsUntilExpiry()! <= 0;
}
}
/**
* ## RTC Membership
* Gets the primary transport to use for this RTC membership (m.rtc.member).
* This will return the primary transport that is used by this call membership to publish their media.
* Directly relates to the `rtc_transports` field.
*
* ## Legacy session membership
* In case of a legacy session membership (m.call.member) this will return the selected transport where
* media is published. How this selection happens depends on the `focus_active` field of the session membership.
* If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership
* in the room (based on the `created_ts` field of the session membership).
* If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list.
* (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work).
* @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership.
* Always required to make the consumer not care if it deals with RTC or session memberships.
* @returns The transport this membership uses to publish media or undefined if no transport is available.
*/
public getTransport(oldestMembership: CallMembership): Transport | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.rtc_transports[0];
case "session":
switch (data.focus_active.focus_selection) {
case "multi_sfu":
return data.foci_preferred[0];
case "oldest_membership":
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
break;
}
}
return undefined;
}
/**
* The focus_active filed of the session membership (m.call.member).
* @deprecated focus_active is not used and will be removed in future versions.
*/
public getFocusActive(): LivekitFocusSelection | undefined {
const { kind, data } = this.membershipData;
if (kind === "session") return data.focus_active;
return undefined;
}
/**
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
*/
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.rtc_transports;
case "session":
default:
return data.foci_preferred;
}
}
public get kind(): MembershipData["kind"] {
return this.membershipData.kind;
}
}
+99 -64
View File
@@ -5,13 +5,15 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts";
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type ParticipantId, type Statistics } from "./types.ts";
import { getParticipantId } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
RoomAndToDeviceTransport,
} from "./RoomAndToDeviceKeyTransport.ts";
import { isMyMembership, type EncryptionKeyMapKey, type Statistics } from "./types.ts";
/**
* The string used for the keys in the the encryption key map.
* `@bob:examle.org:DEVICEID(UUIDRANDOM_MEMBERID_RANDOMUUID)`
*/
export function getEncryptionKeyMapKey(membership: CallMembershipIdentityParts): EncryptionKeyMapKey {
return `${membership.userId}:${membership.deviceId}(${membership.memberId})`;
}
/**
* This interface is for testing and for making it possible to interchange the encryption manager.
@@ -43,9 +45,19 @@ export interface IEncryptionManager {
*
* @returns A map of participant IDs to their encryption keys.
*/
getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>;
getEncryptionKeys(): ReadonlyMap<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
>;
}
export type CallMembershipIdentityParts = Pick<CallMembership, "userId" | "deviceId" | "memberId">;
/**
* This class implements the IEncryptionManager interface,
* and takes care of managing the encryption keys of all rtc members:
@@ -72,7 +84,10 @@ export class EncryptionManager implements IEncryptionManager {
return this.joinConfig?.useKeyDelay ?? 5_000;
}
private encryptionKeys = new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
private encryptionKeys = new Map<
string,
Array<{ key: Uint8Array<ArrayBuffer>; timestamp: number; membership: CallMembershipIdentityParts }>
>();
private lastEncryptionKeyUpdateRequest?: number;
// We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys
@@ -84,29 +99,52 @@ export class EncryptionManager implements IEncryptionManager {
private logger: Logger;
public constructor(
private userId: string,
private deviceId: string,
private membership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: (
keyBin: Uint8Array,
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
participantId: string,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => void,
parentLogger?: Logger,
) {
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
}
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
const keysMap = new Map<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>();
for (const [userId, userKeys] of this.encryptionKeys) {
const keys = userKeys.map((entry, index) => ({
private rtcBackendIdentityFromMembershipParts(membership: CallMembershipIdentityParts): string {
// Implement logic to construct rtcBackendIdentity from membership parts
return `${membership.userId}:${membership.deviceId}`;
}
public getEncryptionKeys(): ReadonlyMap<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
> {
const keysMap = new Map<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
>();
for (const [userId, userKeyEntry] of this.encryptionKeys) {
const keys = userKeyEntry.map((entry, index) => ({
key: entry.key,
membership: entry.membership,
keyIndex: index,
rtcBackendIdentity: this.rtcBackendIdentityFromMembershipParts(entry.membership),
}));
keysMap.set(userId as ParticipantId, keys);
keysMap.set(userId as EncryptionKeyMapKey, keys);
}
return keysMap;
}
@@ -119,10 +157,7 @@ export class EncryptionManager implements IEncryptionManager {
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
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();
if (this.joinConfig?.manageMediaKeys) {
this.makeNewSenderKey();
@@ -134,7 +169,7 @@ export class EncryptionManager implements IEncryptionManager {
// clear our encryption keys as we're done with them now (we'll
// make new keys if we rejoin). We leave keys for other participants
// as they may still be using the same ones.
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
this.encryptionKeys.set(getEncryptionKeyMapKey(this.membership), []);
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
@@ -155,13 +190,13 @@ export class EncryptionManager implements IEncryptionManager {
if (this.manageMediaKeys && this.joined) {
const oldMembershipIds = new Set(
oldMemberships
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map(getParticipantIdFromMembership),
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map(getEncryptionKeyMapKey),
);
const newMembershipIds = new Set(
this.getMemberships()
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map(getParticipantIdFromMembership),
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map(getEncryptionKeyMapKey),
);
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
@@ -210,14 +245,7 @@ export class EncryptionManager implements IEncryptionManager {
const encryptionKey = secureRandomBase64Url(16);
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
this.logger.info("Generated new key at index " + encryptionKeyIndex);
this.setEncryptionKey(
this.userId,
this.deviceId,
encryptionKeyIndex,
encryptionKey,
Date.now(),
delayBeforeUse,
);
this.setEncryptionKey(this.membership, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
return encryptionKeyIndex;
}
@@ -248,12 +276,11 @@ export class EncryptionManager implements IEncryptionManager {
/**
* Get the known encryption keys for a given participant device.
*
* @param userId the user ID of the participant
* @param deviceId the device ID of the participant
* @param membership - The membership identity parts of the participant
* @returns The encryption keys for the given participant, or undefined if they are not known.
*/
private getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
return this.encryptionKeys.get(getParticipantId(userId, deviceId))?.map((entry) => entry.key);
private getKeysForParticipant(membership: CallMembershipIdentityParts): Array<Uint8Array<ArrayBuffer>> | undefined {
return this.encryptionKeys.get(getEncryptionKeyMapKey(membership))?.map((entry) => entry.key);
}
/**
@@ -268,7 +295,7 @@ export class EncryptionManager implements IEncryptionManager {
if (!this.joined) return;
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
const myKeys = this.getKeysForParticipant(this.membership);
if (!myKeys) {
this.logger.warn("Tried to send encryption keys event but no keys found!");
@@ -302,7 +329,7 @@ export class EncryptionManager implements IEncryptionManager {
});
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
`sendEncryptionKeysEvent participantId=${this.membership.userId}:${this.membership.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
);
} catch (error) {
if (this.keysEventUpdateTimeout === undefined) {
@@ -315,20 +342,18 @@ export class EncryptionManager implements IEncryptionManager {
}
};
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
this.requestSendCurrentKey();
};
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
this.logger.debug(
`Received key over key transport ${membership.userId}:${membership.deviceId} at index ${index}`,
);
this.setEncryptionKey(membership, index, keyBase64Encoded, timestamp);
};
private storeLastMembershipFingerprints(): void {
this.lastMembershipFingerprints = new Set(
this.getMemberships()
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map((m) => `${getEncryptionKeyMapKey(m)}:${m.createdTs()}`),
);
}
@@ -356,28 +381,29 @@ export class EncryptionManager implements IEncryptionManager {
* be distributed.
*/
private setEncryptionKey(
userId: string,
deviceId: string,
membership: CallMembershipIdentityParts,
encryptionKeyIndex: number,
encryptionKeyString: string,
timestamp: number,
delayBeforeUse = false,
): void {
this.logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
this.logger.debug(
`Setting encryption key for ${membership.userId}:${membership.deviceId} at index ${encryptionKeyIndex}`,
);
const keyBin = decodeBase64(encryptionKeyString);
const participantId = getParticipantId(userId, deviceId);
if (!this.encryptionKeys.has(participantId)) {
this.encryptionKeys.set(participantId, []);
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.encryptionKeys.has(mapKey)) {
this.encryptionKeys.set(mapKey, []);
}
const participantKeys = this.encryptionKeys.get(participantId)!;
const participantKeys = this.encryptionKeys.get(mapKey)!;
const existingKeyAtIndex = participantKeys[encryptionKeyIndex];
if (existingKeyAtIndex) {
if (existingKeyAtIndex.timestamp > timestamp) {
this.logger.info(
`Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`,
`Ignoring new key at index ${encryptionKeyIndex} for ${mapKey} as it is older than existing known key`,
);
return;
}
@@ -388,7 +414,7 @@ export class EncryptionManager implements IEncryptionManager {
}
}
if (userId === this.userId && deviceId === this.deviceId) {
if (membership.userId === this.membership.userId && membership.deviceId === this.membership.deviceId) {
// It is important to already update the latestGeneratedKeyIndex here
// NOT IN THE `delayBeforeUse` `setTimeout`.
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
@@ -400,18 +426,29 @@ export class EncryptionManager implements IEncryptionManager {
participantKeys[encryptionKeyIndex] = {
key: keyBin,
timestamp,
membership: membership,
};
if (delayBeforeUse) {
const useKeyTimeout = setTimeout(() => {
this.setNewKeyTimeouts.delete(useKeyTimeout);
this.logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
this.logger.info(`Delayed-emitting key changed event for ${mapKey} index ${encryptionKeyIndex}`);
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
this.onEncryptionKeysChanged(
keyBin,
encryptionKeyIndex,
membership,
this.rtcBackendIdentityFromMembershipParts(membership),
);
}, this.useKeyDelay);
this.setNewKeyTimeouts.add(useKeyTimeout);
} else {
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
this.onEncryptionKeysChanged(
keyBin,
encryptionKeyIndex,
membership,
this.rtcBackendIdentityFromMembershipParts(membership),
);
}
}
@@ -431,5 +468,3 @@ function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolea
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
}
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);
+2 -2
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
export enum KeyTransportEvents {
@@ -27,8 +28,7 @@ export type KeyTransportEventsHandlerMap = {
};
export type KeyTransportEventListener = (
userId: string,
deviceId: string,
membership: CallMembershipIdentityParts,
keyBase64Encoded: string,
index: number,
timestamp: number,
+26 -9
View File
@@ -15,8 +15,7 @@ limitations under the License.
*/
import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
import type { Status } from "./types.ts";
import type { RTCCallIntent, Status, Transport } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent {
@@ -27,11 +26,17 @@ export enum MembershipManagerEvent {
* This means that the user is probably not joined anymore and the leave event was distributed to other session members.
*/
ProbablyLeft = "ProbablyLeft",
/**
* Once the membershipManger has aquired the a delay id (after sending the state event)
* It will emit and share the delay id.
*/
DelayIdChanged = "DelayIdChanged",
}
export type MembershipManagerEventHandlerMap = {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
[MembershipManagerEvent.ProbablyLeft]: (probablyLeft: boolean) => void;
[MembershipManagerEvent.DelayIdChanged]: (delayId: string | undefined) => void;
};
/**
@@ -41,8 +46,10 @@ export type MembershipManagerEventHandlerMap = {
*
* @internal
*/
export interface IMembershipManager
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap> {
export interface IMembershipManager extends TypedEventEmitter<
MembershipManagerEvent,
MembershipManagerEventHandlerMap
> {
/**
* If we are trying to join, or have successfully joined the session.
* It does not reflect if the room state is already configured to represent us being joined.
@@ -77,13 +84,22 @@ export interface IMembershipManager
*/
get probablyLeft(): boolean;
/**
* If the membership manager has reason to believe that the hs sent a leave event
* and as a consequence the current user is perceived as left for other session participants.
*/
get delayId(): string | undefined;
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* @param fociActive the active focus to use in the joined RTC membership event.
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
* transport selection will be used instead.
* @throws can throw if it exceeds a configured maximum retry.
*/
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
@@ -95,9 +111,10 @@ export interface IMembershipManager
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @returns the used active focus in the currently joined session or undefined if not joined.
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
* @param callIntent The new intent to set.
*/
getActiveFocus(): Focus | undefined;
updateCallIntent(callIntent: RTCCallIntent): Promise<void>;
}
-39
View File
@@ -1,39 +0,0 @@
/*
Copyright 2023 New Vector Ltd
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 Focus } from "./focus.ts";
export interface LivekitFocusConfig extends Focus {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitFocus extends LivekitFocusConfig {
livekit_alias: string;
}
export const isLivekitFocus = (object: any): object is LivekitFocus =>
isLivekitFocusConfig(object) && "livekit_alias" in object;
export interface LivekitFocusActive extends Focus {
type: "livekit";
focus_selection: "oldest_membership";
}
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
object.type === "livekit" && "focus_selection" in object;
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2025 New Vector Ltd
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 Transport } from "./types.ts";
export interface LivekitTransportConfig extends Transport {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitTransport extends LivekitTransportConfig {
livekit_alias: string;
}
export const isLivekitTransport = (object: any): object is LivekitTransport =>
isLivekitTransportConfig(object) && "livekit_alias" in object;
/**
* @deprecated, this is just needed for the old focus active / focus fields of a call membership.
* Not needed for new implementations.
*/
export interface LivekitFocusSelection extends Transport {
type: "livekit";
focus_selection: "oldest_membership" | "multi_sfu";
}
/**
* @deprecated see LivekitFocusSelection
*/
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
object.type === "livekit" && "focus_selection" in object;
+358 -180
View File
@@ -24,31 +24,29 @@ import { KnownMembership } from "../@types/membership.ts";
import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { MembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import {
type Statistics,
type RTCNotificationType,
type Status,
type IRTCNotificationContent,
type ICallNotifyContent,
import type {
Statistics,
RTCNotificationType,
Status,
IRTCNotificationContent,
ICallNotifyContent,
RTCCallIntent,
Transport,
} from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import {
MembershipManagerEvent,
type MembershipManagerEventHandlerMap,
type IMembershipManager,
} from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
RoomAndToDeviceTransport,
} from "./RoomAndToDeviceKeyTransport.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { type MatrixEvent } from "../models/event.ts";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
/**
* Events emitted by MatrixRTCSession
@@ -75,9 +73,10 @@ export type MatrixRTCSessionEventHandlerMap = {
) => void;
[MatrixRTCSessionEvent.JoinStateChanged]: (isJoined: boolean) => void;
[MatrixRTCSessionEvent.EncryptionKeyChanged]: (
key: Uint8Array,
key: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
participantId: string,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => void;
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
[MatrixRTCSessionEvent.DidSendCallNotification]: (
@@ -92,15 +91,27 @@ export interface SessionConfig {
* @default `undefined` (no notification)
*/
notificationType?: RTCNotificationType;
/**
* Determines the kind of call this will be.
*/
callIntent?: RTCCallIntent;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SessionDescription {
export interface SlotDescription {
id: string;
application: string;
}
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id] = slotId.split("#");
return { application, id };
}
export function slotDescriptionToId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
@@ -110,14 +121,6 @@ export interface SessionDescription {
// - we use a `Ms` postfix if the option is a duration to avoid using words like:
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
export interface MembershipConfig {
/**
* Use the new Manager.
*
* Default: `false`.
* @deprecated does nothing anymore we always default to the new membership manager.
*/
useNewMembershipManager?: boolean;
/**
* The timeout (in milliseconds) after we joined the call, that our membership should expire
* unless we have explicitly updated it.
@@ -176,9 +179,18 @@ export interface MembershipConfig {
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
* but only applies to calls to the `_unstable_restartScheduledDelayedEvent` endpoint
* or the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayedLeaveEventRestartLocalTimeoutMs?: number;
/**
* Send membership using sticky events rather than state events.
* This also make the client use the new m.rtc.member MSC4354 event format. (instead of m.call.member)
*
* **WARNING**: This is an unstable feature and not all clients will support it.
*/
unstableSendStickyEvents?: boolean;
}
export interface EncryptionConfig {
@@ -224,18 +236,29 @@ export interface EncryptionConfig {
}
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
interface SessionMembershipsForSlotOpts {
/**
* Listen for incoming sticky member events. If disabled, this session will
* ignore any incoming sticky events.
*/
listenForStickyEvents: boolean;
/**
* Listen for incoming member state events (legacy). If disabled, this session will
* ignore any incoming state events.
*/
listenForMemberStateEvents: boolean;
}
/**
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
* This class doesn't deal with media at all, just membership & properties of a session.
*/
export class MatrixRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
MatrixRTCSessionEvent | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
> {
private membershipManager?: IMembershipManager;
private encryptionManager?: IEncryptionManager;
// The session Id of the call, this is the call_id of the call Member event.
private _callId: string | undefined;
private joinConfig?: SessionConfig;
private logger: Logger;
@@ -247,6 +270,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
private expiryTimeout?: ReturnType<typeof setTimeout>;
public memberships: CallMembership[] = [];
/**
* The statistics for this session.
*/
@@ -263,94 +288,142 @@ export class MatrixRTCSession extends TypedEventEmitter<
public get membershipStatus(): Status | undefined {
return this.membershipManager?.status;
}
public get probablyLeft(): boolean | undefined {
return this.membershipManager?.probablyLeft;
}
public get delayId(): string | undefined {
return this.membershipManager?.delayId;
}
/**
* The callId (sessionId) of the call.
*
* It can be undefined since the callId is only known once the first membership joins.
* The callId is the property that, per definition, groups memberships into one call.
* @deprecated use `slotId` instead.
*/
public get callId(): string | undefined {
return this._callId;
return this.slotDescription?.id;
}
/**
* The slotId of the call.
* `{application}#{appSpecificId}`
* It can be undefined since the slotId is only known once the first membership joins.
* The slotId is the property that, per definition, groups memberships into one call.
*/
public get slotId(): string | undefined {
return slotDescriptionToId(this.slotDescription);
}
/**
*
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
* @returns an empty array.
*/
public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
): CallMembership[] {
rootLogger.error(
`[MatrixRTCSession ${room.roomId}] callMembershipsForRoom is deprecated. Use sessionMembershipsForSlot instead.`,
);
return [];
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
* By default, this will return *both* sticky and member state events.
*/
public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
): CallMembership[] {
return MatrixRTCSession.sessionMembershipsForRoom(room, {
id: "",
application: "m.call",
});
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*/
public static sessionMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
sessionDescription: SessionDescription,
): CallMembership[] {
public static async sessionMembershipsForSlot(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
slotDescription: SlotDescription,
// default both true this implied we combine sticky and state events for the final call state
// (prefer sticky events in case of a duplicate)
{ listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForSlotOpts = {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
},
): Promise<CallMembership[]> {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) {
logger.warn("Couldn't get state for room " + room.roomId);
throw new Error("Could't get state for room " + room.roomId);
let callMemberEvents = [] as MatrixEvent[];
if (listenForStickyEvents) {
// prefill with sticky events
callMemberEvents = [...room._unstable_getStickyEvents()].filter(
(e) => e.getType() === EventType.RTCMembership,
);
}
if (listenForMemberStateEvents) {
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) {
logger.warn("Couldn't get state for room " + room.roomId + "using empty membership array");
return [];
}
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
callMemberEvents = callMemberEvents.concat(
callMemberStateEvents.filter(
(callMemberStateEvent) =>
!callMemberEvents.some(
// only care about state events which have keys which we have not yet seen in the sticky events.
(stickyEvent) =>
stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(),
),
),
);
}
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
const callMemberships: CallMembership[] = [];
for (const memberEvent of callMemberEvents) {
const content = memberEvent.getContent();
const eventKeysCount = Object.keys(content).length;
// Ignore sticky keys for the count
const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length;
// Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms)
if (eventKeysCount === 0) continue;
const membershipContents: any[] = [];
let membershipContent = undefined;
// We first decide if its a MSC4143 event (per device state key)
if (eventKeysCount > 1 && "focus_active" in content) {
if (eventKeysCount > 1 && "application" in content) {
// We have a MSC4143 event membership event
membershipContents.push(content);
membershipContent = content;
} else if (eventKeysCount === 1 && "memberships" in content) {
logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`);
}
if (membershipContents.length === 0) continue;
if (membershipContent === undefined) continue;
for (const membershipData of membershipContents) {
try {
const membership = new CallMembership(memberEvent, membershipData);
if (!("application" in membershipContent)) {
// This is a left membership event, ignore it here to not log warnings.
continue;
}
try {
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
logger.info(
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
);
continue;
}
const membership = new CallMembership(
memberEvent,
membershipData,
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
rootLogger,
);
if (membership.isExpired()) {
logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`);
continue;
}
if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) {
logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`);
continue;
}
callMemberships.push(membership);
} catch (e) {
logger.warn("Couldn't construct call membership: ", e);
if (!deepCompare(membership.slotDescription, slotDescription)) {
logger.info(
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
);
continue;
}
if (membership.isExpired()) {
logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`);
continue;
}
if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) {
logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`);
continue;
}
callMemberships.push(membership);
} catch (e) {
logger.warn("Couldn't construct call membership: ", e);
}
}
@@ -358,41 +431,25 @@ export class MatrixRTCSession extends TypedEventEmitter<
if (callMemberships.length > 1) {
logger.debug(
`Call memberships in room ${room.roomId}, in order: `,
callMemberships.map((m) => [m.createdTs(), m.sender]),
callMemberships.map((m) => [m.createdTs(), m.userId]),
);
}
return callMemberships;
}
/**
* Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active room call sessions
* for the requested room.
*
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
* sessionDescription `{ id: "", application: "m.call" }`.
*
* @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
*/
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
}
/**
* Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active sessions
* for the requested room and `sessionDescription`.
* for the requested room and `slotDescription`.
*/
public static sessionForRoom(
public static sessionForSlot(
client: MatrixClient,
room: Room,
sessionDescription: SessionDescription,
slotDescription: SlotDescription,
opts?: SessionMembershipsForSlotOpts,
): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
return new MatrixRTCSession(client, room, slotDescription, opts);
}
/**
@@ -415,17 +472,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
*
* @param client A subset of the {@link MatrixClient} that lets the session interact with the Matrix room.
* @param roomSubset The room this session is attached to. A subset of a js-sdk Room that the session needs.
* @param memberships The list of memberships this session currently has.
* @param slotDescription The slot description is a virtual address where participants are allowed to meet.
* This session will only manage memberships that match this slot description.Sessions are distinct if any of
* those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
*/
public constructor(
private readonly client: Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendEvent"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "sendEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
| "encryptAndSendToDevice"
| "off"
@@ -436,22 +500,21 @@ export class MatrixRTCSession extends TypedEventEmitter<
Room,
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
>,
public memberships: CallMembership[],
/**
* The session description is used to define the exact session this object is tracking.
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
*/
public readonly sessionDescription: SessionDescription,
public readonly slotDescription: SlotDescription,
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this._callId = memberships[0]?.sessionDescription.id;
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.roomSubset.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
// We can ignore this promise because `recalculateSessionMembers` will emit
// `MatrixRTCSessionEvent.MembershipsChanged` once it has completed.
this.ensureRecalculateSessionMembers();
this.setExpiryTimer();
}
/*
* Returns true if we intend to be participating in the MatrixRTC session.
* This is determined by checking if the relativeExpiry has been set.
@@ -469,12 +532,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
clearTimeout(this.expiryTimeout);
this.expiryTimeout = undefined;
}
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.roomSubset.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
}
private reEmitter = new TypedReEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
MatrixRTCSessionEvent | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
>(this);
/**
@@ -484,28 +549,36 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This will not subscribe to updates: remember to call subscribe() separately if
* desired.
* This method will return immediately and the session will be joined in the background.
*
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
* or optionally other room members homeserver well known.
* @param ownMembershipIdentity the identity of the user and device joining the session.
* This will be put into the content.member.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
* transport selection will be used instead.
* @param joinConfig - Additional configuration for the joined session.
*/
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
public joinRTCSession(
ownMembershipIdentity: CallMembershipIdentityParts,
fociPreferred: Transport[],
multiSfuFocus?: Transport,
joinConfig?: JoinSessionConfig,
): void {
if (this.isJoined()) {
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
return;
} else {
// Create MembershipManager and pass the RTCSession logger (with room id info)
this.membershipManager = new MembershipManager(
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.sessionDescription,
this.logger,
);
this.membershipManager = joinConfig?.unstableSendStickyEvents
? new StickyEventMembershipManager(
joinConfig,
this.roomSubset,
this.client,
this.slotDescription,
ownMembershipIdentity.memberId,
this.logger,
)
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
this.reEmitter.reEmit(this.membershipManager!, [
MembershipManagerEvent.ProbablyLeft,
@@ -516,45 +589,49 @@ export class MatrixRTCSession extends TypedEventEmitter<
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];
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
// Expose the changes so the ui can display the currently used transport.
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
this.encryptionManager = new RTCEncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
ownMembershipIdentity,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
membership,
rtcBackendIdentity,
);
},
this.logger,
);
} else {
// TODO REMOVE ME!
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
ownMembershipIdentity,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
membership,
rtcBackendIdentity,
);
},
);
@@ -565,7 +642,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.pendingNotificationToSend = this.joinConfig?.notificationType;
// Join!
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => {
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
@@ -575,6 +652,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
}
/**
*
* @param fociPreferred
* @param multiSfuFocus
* @param joinConfig
* @deprecated use the joinRTCSession method instead
*/
public joinRoomSession(
fociPreferred: Transport[],
multiSfuFocus?: Transport,
joinConfig?: JoinSessionConfig,
): void {
const [userId, deviceId] = [this.client.getUserId()!, this.client.getDeviceId()!];
// TODO this wants to become a UUID
const memberId = `${userId}:${deviceId}`;
this.joinRTCSession({ userId, deviceId, memberId }, fociPreferred, multiSfuFocus, joinConfig);
}
/**
* Announces this user and device as having left the MatrixRTC session
* and stops scheduled updates.
@@ -600,32 +695,51 @@ export class MatrixRTCSession extends TypedEventEmitter<
return await leavePromise;
}
/**
* Get the active focus from the current CallMemberState event
* @returns The focus that is currently in use to connect to this session. This is undefined
* if the client is not connected to this session.
* This returns the focus in use by the oldest membership.
* Do not use since this might be just the focus for the oldest membership. others might use a different focus.
* @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for.
*/
public getActiveFocus(): Focus | undefined {
return this.membershipManager?.getActiveFocus();
public getFocusInUse(): Transport | undefined {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getTransport(oldestMembership);
}
/**
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
* @deprecated does not work with m.rtc.member. Do not rely on it.
*/
public getActiveFocus(): Transport | undefined {
return this.getOldestMembership()?.getFocusActive();
}
public getOldestMembership(): CallMembership | undefined {
return this.memberships[0];
}
/**
* This method is used when the user is not yet connected to the Session but wants to know what focus
* the users in the session are using to make a decision how it wants/should connect.
* Get the call intent for the current call, based on what members are advertising. If one or more
* members disagree on the current call intent, or nobody specifies one then `undefined` is returned.
*
* See also `getActiveFocus`
* @returns The focus which should be used when joining this session.
* If all members that specify a call intent agree, that value is returned.
* @returns A call intent, or `undefined` if no consensus or not given.
*/
public getFocusInUse(): Focus | undefined {
const oldestMembership = this.getOldestMembership();
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
return oldestMembership.getPreferredFoci()[0];
public getConsensusCallIntent(): RTCCallIntent | undefined {
const getFirstCallIntent = this.memberships.find((m) => !!m.callIntent)?.callIntent;
if (!getFirstCallIntent) {
return undefined;
}
if (this.memberships.every((m) => !m.callIntent || m.callIntent === getFirstCallIntent)) {
return getFirstCallIntent;
}
return undefined;
}
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
const myMembership = this.membershipManager?.ownMembership;
if (!myMembership) {
throw Error("Not connected yet");
}
await this.membershipManager?.updateCallIntent(callIntent);
}
/**
@@ -633,9 +747,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
* the keys.
*/
public reemitEncryptionKeys(): void {
this.encryptionManager?.getEncryptionKeys().forEach((keyRing, participantId) => {
this.encryptionManager?.getEncryptionKeys().forEach((keyRing, key) => {
keyRing.forEach((keyInfo) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyInfo.key, keyInfo.keyIndex, participantId);
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyInfo.key,
keyInfo.keyIndex,
keyInfo.membership,
keyInfo.rtcBackendIdentity,
);
});
});
}
@@ -660,14 +780,22 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
if (soonestExpiry != undefined) {
this.expiryTimeout = setTimeout(this.onRTCSessionMemberUpdate, soonestExpiry);
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
}
}
/**
* Sends a notification corresponding to the configured notify type.
* Sends notification events to indiciate the call has started.
* Note: This does not return a promise, instead scheduling the notification events to be sent.
* @param parentEventId Event id linking to your RTC call membership event.
* @param notificationType The type of notification to send
* @param callIntent The type of call this is (e.g. "audio").
*/
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
private sendCallNotify(
parentEventId: string,
notificationType: RTCNotificationType,
callIntent?: RTCCallIntent,
): void {
const sendLegacyNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: ICallNotifyContent;
@@ -695,6 +823,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
"sender_ts": Date.now(),
"lifetime": 30_000, // 30 seconds
};
if (callIntent) {
content["m.call.intent"] = callIntent;
}
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content);
return { response, content };
};
@@ -714,17 +845,55 @@ export class MatrixRTCSession extends TypedEventEmitter<
/**
* Call this when the Matrix room members have changed.
*/
public onRoomMemberUpdate = (): void => {
this.recalculateSessionMembers();
private readonly onRoomMemberUpdate = (): void => {
this.ensureRecalculateSessionMembers();
};
/**
* Call this when a sticky event update has occured.
*/
private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = (
added,
updated,
removed,
): void => {
if (
[...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some(
(e) => e.getType() === EventType.RTCMembership,
)
) {
this.ensureRecalculateSessionMembers();
}
};
/**
* Call this when something changed that may impacts the current MatrixRTC members in this session.
*/
public onRTCSessionMemberUpdate = (): void => {
this.recalculateSessionMembers();
// We allow this name schema since this function should only be used for testing purposes.
// eslint-disable-next-line @typescript-eslint/naming-convention
public _onRTCSessionMemberUpdate = async (): Promise<void> => {
await this.recalculateSessionMembers();
};
// helper variables to make sure we do not have parallel running recalculations.
private recalculateSessionMembersDirty = false;
private recalculateSessionMembersPromise: Promise<void> | undefined = undefined;
private ensureRecalculateSessionMembers(): void {
if (this.recalculateSessionMembersPromise === undefined) {
this.recalculateSessionMembersPromise = this.recalculateSessionMembers().then(() => {
this.recalculateSessionMembersPromise = undefined;
if (this.recalculateSessionMembersDirty) {
this.ensureRecalculateSessionMembers();
this.recalculateSessionMembersDirty = false;
}
});
} else {
this.recalculateSessionMembersDirty = true;
}
}
/**
* Call this when anything that could impact rtc memberships has changed: Room Members or RTC members.
*
@@ -732,11 +901,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
*
* This function should be called when the room members or call memberships might have changed.
*/
private recalculateSessionMembers = (): void => {
private recalculateSessionMembers = async (): Promise<void> => {
const oldMemberships = this.memberships;
this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription);
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
this.room,
this.slotDescription,
this.calculateMembershipsOpts,
);
const changed =
oldMemberships.length != this.memberships.length ||
@@ -757,7 +929,11 @@ export class MatrixRTCSession extends TypedEventEmitter<
// If we're the first member in the call, we're responsible for
// sending the notification event
if (ownMembership.eventId && this.joinConfig?.notificationType) {
this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType);
this.sendCallNotify(
ownMembership.eventId,
this.joinConfig.notificationType,
ownMembership.callIntent,
);
} else {
this.logger.warn("Own membership eventId is undefined, cannot send call notification");
}
@@ -765,6 +941,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
// If anyone else joins the session it is no longer our responsibility to send the notification.
// (If we were the joiner we already did sent the notification in the block above.)
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
} else {
this.logger.debug(`No membership changes detected for room ${this.roomSubset.roomId}`);
}
// This also needs to be done if `changed` = false
// A member might have updated their fingerprint (created_ts)
+30 -12
View File
@@ -18,9 +18,9 @@ import { type Logger } from "../logger.ts";
import { type MatrixClient, ClientEvent } from "../client.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
export enum MatrixRTCSessionManagerEvents {
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
@@ -66,13 +66,14 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
// returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
const session = MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription);
if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session);
}
}
this.client.on(ClientEvent.Room, this.onRoom);
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(RoomStateEvent.Events, this.onRoomState);
}
@@ -83,6 +84,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
this.roomSessions.clear();
this.client.off(ClientEvent.Room, this.onRoom);
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(RoomStateEvent.Events, this.onRoomState);
}
@@ -102,7 +104,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set(
room.roomId,
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription),
);
}
@@ -110,22 +112,34 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
}
private onRoom = (room: Room): void => {
this.refreshRoom(room);
void this.refreshRoom(room);
};
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
private readonly onEvent = (event: MatrixEvent): void => {
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
if (event.getType() !== EventType.RTCMembership) return;
const room = this.client.getRoom(event.getRoomId());
if (!room) return;
void this.refreshRoom(room);
};
private readonly onRoomState = (event: MatrixEvent): void => {
if (event.getType() !== EventType.GroupCallMemberPrefix) {
return;
}
const room = this.client.getRoom(event.getRoomId());
if (!room) {
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return;
}
if (event.getType() == EventType.GroupCallMemberPrefix) {
this.refreshRoom(room);
}
void this.refreshRoom(room);
};
private refreshRoom(room: Room): void {
private async refreshRoom(room: Room): Promise<void> {
const isNewSession = !this.roomSessions.has(room.roomId);
const session = this.getRoomSession(room);
@@ -135,7 +149,11 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// wasActiveAndKnown = session.memberships.length > 0 and
// nowActive = session.memberships.length
// Alternatively we would need to setup some event emission when the RTC session ended.
session.onRTCSessionMemberUpdate();
// TODO we want to add the emission en session end. This makes the responsibility of the session manager more clear.
await session._onRTCSessionMemberUpdate().catch((error) => {
this.logger.error(`Error updating RTC session members for ${room.roomId}: ${error}`);
});
const nowActive = session.memberships.length > 0;
+241 -122
View File
@@ -15,20 +15,29 @@ limitations under the License.
*/
import { AbortError } from "p-retry";
import { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import { type MatrixClient } from "../client.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import { EventType, RelationType } from "../@types/event.ts";
import { type ISendEventResponse, type SendDelayedEventResponse } from "../@types/requests.ts";
import { type EmptyObject } from "../@types/common.ts";
import type { MatrixClient } from "../client.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 { 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 SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts";
import {
type CallMembership,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
type SessionMembershipData,
} from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import {
type SlotDescription,
type MembershipConfig,
type SessionConfig,
slotDescriptionToId,
} from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import {
MembershipManagerEvent,
type IMembershipManager,
@@ -36,12 +45,11 @@ import {
} from "./IMembershipManager.ts";
/* MembershipActionTypes:
On Join: (1)
SendDelayedEvent (2)
(3)
@@ -52,9 +60,9 @@ On Join: ───────────────┐ ┌─────
UpdateExpiry (s) (s)|RestartDelayedEvent
On Leave: STOP ALL ABOVE
@@ -77,6 +85,12 @@ On Leave: ───────── STOP ALL ABOVE
(s) Successful restart/resend
*/
/**
* Call membership should always remain sticky for this amount
* of time.
*/
const MEMBERSHIP_STICKY_DURATION_MS = 60 * 60 * 1000; // 60 minutes
/**
* The different types of actions the MembershipManager can take.
* @internal
@@ -137,6 +151,30 @@ export interface MembershipManagerState {
probablyLeft: boolean;
}
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
return {
insert: [{ ts: Date.now() + (offset ?? 0), type }],
};
}
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
return {
replace: [{ ts: Date.now() + (offset ?? 0), type }],
};
}
type MembershipManagerClient = Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
>;
/**
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
* It has the following tasks:
@@ -155,7 +193,8 @@ export class MembershipManager
implements IMembershipManager
{
private activated = false;
private logger: Logger;
private readonly logger: Logger;
protected callIntent: RTCCallIntent | undefined;
public isActivated(): boolean {
return this.activated;
@@ -168,18 +207,21 @@ export class MembershipManager
/**
* Puts the MembershipManager in a state where it tries to be joined.
* It will send delayed events and membership events
* @param fociPreferred
* @param focusActive
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
* transport selection will be used instead.
* @param onError This will be called once the membership manager encounters an unrecoverable error.
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
*/
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void {
if (this.scheduler.running) {
this.logger.error("MembershipManager is already running. Ignoring join request.");
return;
}
this.fociPreferred = fociPreferred;
this.focusActive = focusActive;
this.rtcTransport = multiSfuFocus;
this.leavePromiseResolvers = undefined;
this.activated = true;
this.oldStatus = this.status;
@@ -230,16 +272,14 @@ export class MembershipManager
private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId || !deviceId) {
this.logger.error("MembershipManager.onRTCSessionMemberUpdate called without user or device id");
public onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
if (!this.isActivated()) {
return Promise.resolve();
}
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
if (this.isActivated() && !this._ownMembership) {
this._ownMembership = memberships.find((m) => isMyMembership(m, this.userId, this.deviceId));
if (!this._ownMembership) {
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
// take care of our missing membership.
const sendingMembershipActions = [
@@ -262,23 +302,16 @@ export class MembershipManager
return Promise.resolve();
}
public getActiveFocus(): Focus | undefined {
if (this.focusActive) {
// A livekit active focus
if (isLivekitFocusActive(this.focusActive)) {
if (this.focusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
} else {
this.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];
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
if (!this.activated || !this.ownMembership) {
throw Error("You cannot update your intent before joining the call");
}
if (this.ownMembership.callIntent === callIntent) {
return; // No-op
}
this.callIntent = callIntent;
// Kick off a new membership event as a result.
await this.sendJoinEvent();
}
/**
@@ -286,21 +319,12 @@ export class MembershipManager
* @param joinConfig
* @param room
* @param client
* @param getOldestMembership
*/
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 readonly sessionDescription: SessionDescription,
private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined,
protected readonly room: Pick<Room, "roomId" | "getVersion">,
protected readonly client: MembershipManagerClient,
public readonly slotDescription: SlotDescription,
parentLogger?: Logger,
) {
super();
@@ -309,8 +333,12 @@ export class MembershipManager
if (userId === null) throw Error("Missing userId in client");
if (deviceId === null) throw Error("Missing deviceId in client");
this.deviceId = deviceId;
this.userId = userId;
// this needs to become a uuid so that consecutive join/leaves result in a key rotation.
// we keep it as a string for now for backwards compatibility.
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
this.state = MembershipManager.defaultState;
this.callIntent = joinConfig?.callIntent;
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
if (this.oldStatus) {
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
@@ -353,10 +381,12 @@ export class MembershipManager
};
}
// Membership Event static parameters:
private deviceId: string;
private stateKey: string;
private fociPreferred?: Focus[];
private focusActive?: Focus;
protected deviceId: string;
protected userId: string;
protected stateKey: string;
protected rtcTransport?: Transport;
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
private fociPreferred?: Transport[];
// Config:
private delayedLeaveEventDelayMsOverride?: number;
@@ -371,9 +401,13 @@ export class MembershipManager
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
}
private computeNextExpiryActionTs(iteration: number): number {
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
return (
this.state.startTime +
Math.min(this.membershipEventExpiryMs, MEMBERSHIP_STICKY_DURATION_MS) * iteration -
this.membershipEventExpiryHeadroomMs
);
}
private get delayedLeaveEventDelayMs(): number {
protected get delayedLeaveEventDelayMs(): number {
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
}
private get delayedLeaveEventRestartMs(): number {
@@ -385,10 +419,10 @@ export class MembershipManager
private get maximumNetworkErrorRetryCount(): number {
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
}
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
}
// LOOP HANDLER:
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
switch (type) {
@@ -443,28 +477,29 @@ export class MembershipManager
}
}
// an abstraction to switch between sending state or a sticky event
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
this.client._unstable_sendDelayedStateEvent(
this.room.roomId,
{ delay: this.delayedLeaveEventDelayMs },
EventType.GroupCallMemberPrefix,
{},
this.stateKey,
);
// HANDLERS (used in the membershipLoopHandler)
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
return await this.client
._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.delayedLeaveEventDelayMs,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
this.stateKey,
)
return await this.clientSendDelayedDisconnectMembership()
.then((response) => {
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
this.setAndEmitProbablyLeft(false);
// On success we reset retries and set delayId.
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
this.state.delayId = response.delay_id;
this.setAndEmitDelayId(response.delay_id);
if (this.state.hasMemberStateEvent) {
// This action was scheduled because the previous delayed event was cancelled
// due to lack of https://github.com/element-hq/synapse/pull/17810
@@ -482,7 +517,7 @@ export class MembershipManager
if (this.manageMaxDelayExceededSituation(e)) {
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent");
if (update) return update;
if (this.state.hasMemberStateEvent) {
@@ -509,21 +544,21 @@ export class MembershipManager
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Remove all running updates and restarts
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
._unstable_cancelScheduledDelayedEvent(delayId)
.then(() => {
this.state.delayId = undefined;
this.setAndEmitDelayId(undefined);
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
return createReplaceActionUpdate(MembershipActionType.SendDelayedEvent);
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendDelayedEvent;
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "cancelScheduledDelayedEvent");
if (update) return update;
if (this.isNotFoundError(e)) {
// If we get a M_NOT_FOUND we know that the delayed event got already removed.
// This means we are good and can set it to undefined and run this again.
this.state.delayId = undefined;
this.setAndEmitDelayId(undefined);
return createReplaceActionUpdate(repeatActionType);
}
if (this.isUnsupportedDelayedEndpoint(e)) {
@@ -549,6 +584,13 @@ export class MembershipManager
this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft);
}
private setAndEmitDelayId(delayId?: string): void {
if (this.state.delayId === delayId) return;
this.state.delayId = delayId;
this.emit(MembershipManagerEvent.DelayIdChanged, this.state.delayId);
}
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Compute the duration until we expect the server to send the delayed leave event.
const durationUntilServerDelayedLeave = this.state.expectedServerDelayLeaveTs
@@ -571,10 +613,7 @@ export class MembershipManager
// The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded
// to the widget driver this information would get lost. That is why we mimic the AbortError using the race.
return await Promise.race([
this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart),
abortPromise,
])
return await Promise.race([this.client._unstable_restartScheduledDelayedEvent(delayId), abortPromise])
.then(() => {
// Whenever we successfully restart the delayed event we update the `state.expectedServerDelayLeaveTs`
// which stores the predicted timestamp at which the server will send the delayed leave event if there wont be any further
@@ -595,14 +634,14 @@ export class MembershipManager
}
const repeatActionType = MembershipActionType.RestartDelayedEvent;
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
this.setAndEmitDelayId(undefined);
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
}
// If the HS does not support delayed events we wont reschedule.
if (this.isUnsupportedDelayedEndpoint(e)) return {};
// TODO this also needs a test: get rate limit while checking id delayed event is scheduled
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "restartScheduledDelayedEvent");
if (update) return update;
// In other error cases we have no idea what is happening
@@ -612,7 +651,7 @@ export class MembershipManager
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
._unstable_sendScheduledDelayedEvent(delayId)
.then(() => {
this.state.hasMemberStateEvent = false;
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
@@ -623,10 +662,10 @@ export class MembershipManager
const repeatActionType = MembershipActionType.SendLeaveEvent;
if (this.isUnsupportedDelayedEndpoint(e)) return {};
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
this.setAndEmitDelayId(undefined);
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendScheduledDelayedEvent");
if (update) return update;
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
@@ -638,14 +677,19 @@ export class MembershipManager
});
}
protected clientSendMembership: (
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
) => Promise<ISendEventResponse> = (myMembership) => {
return this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
myMembership as EmptyObject | SessionMembershipData,
this.stateKey,
);
};
private async sendJoinEvent(): Promise<ActionUpdate> {
return await this.client
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs),
this.stateKey,
)
return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs))
.then(() => {
this.setAndEmitProbablyLeft(false);
this.state.startTime = Date.now();
@@ -685,13 +729,9 @@ export class MembershipManager
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
return await this.client
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
this.stateKey,
)
return await this.clientSendMembership(
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
)
.then(() => {
// Success, we reset retries and schedule update.
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
@@ -713,8 +753,7 @@ export class MembershipManager
});
}
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
return await this.client
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
return await this.clientSendMembership({})
.then(() => {
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
this.state.hasMemberStateEvent = false;
@@ -728,8 +767,12 @@ export class MembershipManager
}
// HELPERS
/**
* this creates `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`
* which is not compatible with membershipID of session type member events. They have to be `${localUserId}:${localDeviceId}`
*/
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`;
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
@@ -740,16 +783,32 @@ export class MembershipManager
/**
* Constructs our own membership
*/
private makeMyMembership(expires: number): SessionMembershipData {
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const focusObjects =
this.rtcTransport === undefined
? {
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
foci_preferred: this.fociPreferred ?? [],
}
: {
focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
};
return {
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
application: this.sessionDescription.application,
call_id: this.sessionDescription.id,
scope: "m.room",
device_id: this.deviceId,
"application": this.slotDescription.application,
"call_id": this.slotDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
// DO NOT use this.memberId here since that is the state key (using application...)
// But for session events we use the colon seperated userId and deviceId. The SFU will automatically
// assign those values to the media participant for those versions.
"membershipID": `${this.userId}:${this.deviceId}`,
expires,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.fociPreferred ?? [],
"m.call.intent": this.callIntent,
...focusObjects,
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
};
}
@@ -785,7 +844,7 @@ export class MembershipManager
return false;
}
private actionUpdateFromErrors(
protected actionUpdateFromErrors(
error: unknown,
type: MembershipActionType,
method: string,
@@ -833,7 +892,7 @@ export class MembershipManager
return createInsertActionUpdate(type, resendDelay);
}
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error });
}
/**
@@ -975,16 +1034,76 @@ export class MembershipManager
public get probablyLeft(): boolean {
return this.state.probablyLeft;
}
public get delayId(): string | undefined {
return this.state.delayId;
}
}
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
return {
insert: [{ ts: Date.now() + (offset ?? 0), type }],
};
}
/**
* Implementation of the Membership manager that uses sticky events
* rather than state events.
*/
export class StickyEventMembershipManager extends MembershipManager {
public constructor(
joinConfig: (SessionConfig & MembershipConfig) | undefined,
room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
private readonly clientWithSticky: MembershipManagerClient &
Pick<MatrixClient, "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent">,
sessionDescription: SlotDescription,
// this needs to become a uuid so that consecutive join/leaves result in a key rotation.
// we keep it as a string for now for backwards compatibility.
private readonly memberId: string,
parentLogger?: Logger,
) {
super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger);
}
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
return {
replace: [{ ts: Date.now() + (offset ?? 0), type }],
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
this.clientWithSticky._unstable_sendStickyDelayedEvent(
this.room.roomId,
MEMBERSHIP_STICKY_DURATION_MS,
{ delay: this.delayedLeaveEventDelayMs },
null,
EventType.RTCMembership,
{ msc4354_sticky_key: this.memberId },
);
protected clientSendMembership: (
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
) => Promise<ISendEventResponse> = (myMembership) => {
return this.clientWithSticky._unstable_sendStickyEvent(
this.room.roomId,
MEMBERSHIP_STICKY_DURATION_MS,
null,
EventType.RTCMembership,
{ ...myMembership, msc4354_sticky_key: this.memberId },
);
};
private static nameMap = new Map([
["sendStateEvent", "_unstable_sendStickyEvent"],
["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"],
]);
protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined {
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
}
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const relationObject = ownMembership?.eventId
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
: {};
return {
application: {
type: this.slotDescription.application,
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
},
slot_id: slotDescriptionToId(this.slotDescription),
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
member: { device_id: this.deviceId, user_id: this.userId, id: this.memberId },
versions: [],
...relationObject,
};
}
}
+145 -77
View File
@@ -14,26 +14,25 @@ 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 {
type CallMembershipIdentityParts,
getEncryptionKeyMapKey,
type IEncryptionManager,
} from "./EncryptionManager.ts";
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
import { 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";
type EncryptionKeyMapKey,
type InboundEncryptionSession,
type OutboundEncryptionSession,
type ParticipantDeviceInfo,
type Statistics,
} from "./types.ts";
import { OutdatedKeyFilter } from "./utils.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
@@ -51,12 +50,23 @@ export class RTCEncryptionManager implements IEncryptionManager {
// This will be done when removing the legacy EncryptionManager.
private manageMediaKeys = false;
private useHashedRtcBackendIdentity = false;
private ownRtcBackendIdentityCache: string | undefined;
/**
* Store the key rings for each participant.
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
*/
private participantKeyRings = new Map<ParticipantId, Array<{ key: Uint8Array; keyIndex: number }>>();
private participantKeyRings = new Map<
EncryptionKeyMapKey,
Array<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
>();
// The current per-sender media key for this device
private outboundSession: OutboundEncryptionSession | null = null;
@@ -101,47 +111,122 @@ export class RTCEncryptionManager implements IEncryptionManager {
private logger: Logger | undefined = undefined;
private rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
/**
*
* @param ownMembership - our own membership info
* @param getMemberships - function to get current memberships
* @param transport - key transport (room or to-device)
* @param statistics - statistics collector
* @param onEncryptionKeysChanged - callback to notify the media layer of new keys
* @param parentLogger - optional parent logger
* @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes
*/
public constructor(
private userId: string,
private deviceId: string,
private ownMembership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array,
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
participantId: ParticipantId,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => void,
parentLogger?: Logger,
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw;
}
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
private async getOwnRtcBackendIdentity(): Promise<string> {
if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
if (this.useHashedRtcBackendIdentity) {
const { userId, deviceId, memberId } = this.ownMembership;
this.logger?.info(
// If we see this log multiple times, we need to reconsider the precompute call of getOwnRtcBackendIdentity
`Computing RTC backend identity for ${userId}:${deviceId}:${memberId} (SHOULD ONLY BE CALLED ONCE)`,
);
this.ownRtcBackendIdentityCache = await this.rtcIdentityProvider(userId, deviceId, memberId);
} else {
this.ownRtcBackendIdentityCache = `${this.ownMembership.userId}:${this.ownMembership.deviceId}`;
}
return this.ownRtcBackendIdentityCache;
}
public getEncryptionKeys(): ReadonlyMap<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
> {
return new Map(this.participantKeyRings);
}
private addKeyToParticipant(key: Uint8Array, keyIndex: number, participantId: ParticipantId): void {
if (!this.participantKeyRings.has(participantId)) {
this.participantKeyRings.set(participantId, []);
}
this.participantKeyRings.get(participantId)!.push({ key, keyIndex });
this.onEncryptionKeysChanged(key, keyIndex, participantId);
private keysWithoutMatchingRTCMembership: Array<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
}> = [];
private checkKeysWithoutMatchingRTCMembership(): void {
const keyInfoTemp = this.keysWithoutMatchingRTCMembership;
this.keysWithoutMatchingRTCMembership = [];
keyInfoTemp.forEach((keyInfo) => {
this.addKeyToParticipant(keyInfo.key, keyInfo.keyIndex, keyInfo.membership);
});
}
public join(joinConfig: EncryptionConfig | undefined): void {
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
private addKeyToParticipant(
key: Uint8Array<ArrayBuffer>,
keyIndex: number,
membership: CallMembershipIdentityParts,
): void {
const knownRtcMembership = this.getMemberships();
const fullMembership = knownRtcMembership.find(
(member) => member.userId === membership.userId && member.deviceId === membership.deviceId,
);
if (!fullMembership) {
this.logger?.info(
`No matching RTC membership for key from ${membership.userId}:${membership.deviceId}, delaying key addition`,
);
this.keysWithoutMatchingRTCMembership.push({ key, keyIndex, membership });
return;
}
this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, fullMembership.rtcBackendIdentity);
}
this.logger?.info(`Joining room`);
private addKeyToParticipantWithBackendIdentity(
key: Uint8Array<ArrayBuffer>,
keyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
): void {
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.participantKeyRings.has(mapKey)) {
this.participantKeyRings.set(mapKey, []);
}
this.participantKeyRings.get(mapKey)!.push({ key, keyIndex, membership, rtcBackendIdentity });
this.onEncryptionKeysChanged(key, keyIndex, membership, rtcBackendIdentity);
}
public join(joinConfig: (EncryptionConfig & MembershipConfig) | undefined): void {
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
// Deprecate RoomKeyTransport: this can get removed.
if (this.transport instanceof RoomAndToDeviceTransport) {
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
}
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
void this.getOwnRtcBackendIdentity(); // precompute own identity
this.logger?.info(`Joining room`);
this.transport.start();
}
@@ -151,29 +236,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
this.participantKeyRings.clear();
}
// Temporary for backwards compatibility
// TODO: Remove this in the future
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
this.logger?.info("Transport change detected, restarting key distribution");
if (this.currentKeyDistributionPromise) {
this.currentKeyDistributionPromise
.then(() => {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
})
.catch((e) => {
this.logger?.error("Failed to restart key distribution", e);
});
} else {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
}
};
/**
* Will ensure that a new key is distributed and used to encrypt our media.
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
@@ -199,50 +261,51 @@ export class RTCEncryptionManager implements IEncryptionManager {
}
});
} else {
// There is a rollout in progress, but a key rotation is requested (could be caused by a membership change)
// There is a rollout in progress, but a key rotation is requested (could be caused by a ownMembership 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) => {
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
// `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead
// to create a NoOpEncryptionManager that does nothing and use it for the session.
// This will be done when removing the legacy EncryptionManager.
if (!this.manageMediaKeys) {
this.logger?.warn(
`Received key over transport ${userId}:${deviceId} at index ${index} but media keys are disabled`,
`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index} but media keys are disabled`,
);
return;
}
this.logger?.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`);
this.logger?.debug(`Received key over transport ${membership.userId}:${membership.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,
membership,
keyIndex: index,
creationTS: timestamp,
};
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
const outdated = this.keyBuffer.isOutdated(membership, candidateInboundSession);
if (!outdated) {
this.addKeyToParticipant(
candidateInboundSession.key,
candidateInboundSession.keyIndex,
candidateInboundSession.participantId,
candidateInboundSession.membership,
);
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
} else {
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
this.logger?.info(
`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`,
);
}
};
/**
* Called when the membership of the call changes.
* Called when the ownMembership of the call changes.
* This encryption manager is very basic, it will rotate the key everytime this is called.
* @param oldMemberships - This parameter is not used here, but it is kept for compatibility with the interface.
*/
@@ -252,22 +315,26 @@ export class RTCEncryptionManager implements IEncryptionManager {
// 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();
// ensure key emission to the rtc backend
this.checkKeysWithoutMatchingRTCMembership();
}
private async rolloutOutboundKey(): Promise<void> {
const isFirstKey = this.outboundSession == null;
if (isFirstKey) {
// create the first key
this.outboundSession = {
const firstKey = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: 0,
};
this.addKeyToParticipant(
this.outboundSession.key,
this.outboundSession.keyId,
getParticipantId(this.userId, this.deviceId),
this.outboundSession = firstKey;
this.addKeyToParticipantWithBackendIdentity(
firstKey.key,
firstKey.keyId,
this.ownMembership,
await this.getOwnRtcBackendIdentity(),
);
}
// get current memberships
@@ -285,7 +352,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
// Some users might have rotate their membership event (formally called fingerprint) meaning they might have
// Some users might have rotate their ownMembership event (formally called fingerprint) meaning they might have
// clear their key. Reset the `alreadySharedWith` flag for them.
alreadySharedWith = alreadySharedWith.filter(
(x) =>
@@ -354,10 +421,11 @@ export class RTCEncryptionManager implements IEncryptionManager {
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
await sleep(this.useKeyDelay);
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
this.addKeyToParticipant(
this.addKeyToParticipantWithBackendIdentity(
outboundKey.key,
outboundKey.keyId,
getParticipantId(this.userId, this.deviceId),
this.ownMembership,
await this.getOwnRtcBackendIdentity(),
);
}
} catch (err) {
@@ -386,7 +454,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
return 0;
}
private generateRandomKey(): Uint8Array {
private generateRandomKey(): Uint8Array<ArrayBuffer> {
const key = new Uint8Array(16);
globalThis.crypto.getRandomValues(key);
return key;
@@ -1,131 +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 { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
export interface EnabledTransports {
toDevice: boolean;
room: boolean;
}
export enum RoomAndToDeviceEvents {
EnabledTransportsChanged = "enabled_transports_changed",
}
export type RoomAndToDeviceEventsHandlerMap = {
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
};
/**
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
* The public setEnabled method allows to turn one or the other on or off on the fly.
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
* the user in the ui.
*
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
* if a room key event was received and autoenable it.
*/
export class RoomAndToDeviceTransport
extends TypedEventEmitter<
KeyTransportEvents | RoomAndToDeviceEvents,
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
>
implements IKeyTransport
{
private readonly logger: Logger;
private _enabled: EnabledTransports = { toDevice: true, room: false };
public constructor(
private toDeviceTransport: ToDeviceKeyTransport,
private roomKeyTransport: RoomKeyTransport,
parentLogger?: Logger,
) {
super();
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
this.toDeviceTransport.setParentLogger(this.logger);
this.roomKeyTransport.setParentLogger(this.logger);
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
// Turn on the room transport if we receive a roomKey from another participant
// and disable the toDevice transport.
if (!this._enabled.room) {
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
this.setEnabled({ toDevice: false, room: true });
}
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
});
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
if (this._enabled.toDevice) {
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
} else {
this.logger.debug("To Device transport is disabled, ignoring received keys");
}
});
}
/** Set which transport type should be used to send and receive keys.*/
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
this._enabled = enabled;
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
}
}
/** The currently enabled transports that are used to send and receive keys.*/
public get enabled(): EnabledTransports {
return this._enabled;
}
public start(): void {
// always start the underlying transport since we need to enable room transport
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
this.roomKeyTransport.start();
this.toDeviceTransport.start();
}
public stop(): void {
// always stop since it is always running
this.roomKeyTransport.stop();
this.toDeviceTransport.stop();
}
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" : "") +
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
(this._enabled.toDevice ? "to device transport" : ""),
);
if (this._enabled.room) await this.roomKeyTransport.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);
}
}
}
}
}
+5 -2
View File
@@ -24,6 +24,9 @@ import { type MatrixEvent } from "../models/event.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts";
/**
* @deprecated This is depreacted and not used anymore. use the ToDeviceTransport
*/
export class RoomKeyTransport
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
implements IKeyTransport
@@ -174,8 +177,8 @@ export class RoomKeyTransport
);
this.emit(
KeyTransportEvents.ReceivedKeys,
userId,
deviceId,
// Using `${userId}:${deviceId}` makes no sense (but works). It does not matter since the RoomKeyTransport is deprecated
{ userId, deviceId, memberId: `${userId}:${deviceId}` },
encryptionKey,
encryptionKeyIndex,
event.getTs(),
+15 -8
View File
@@ -23,6 +23,7 @@ import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, ty
import { ClientEvent, type MatrixClient } from "../client.ts";
import type { MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
export class NotSupportedError extends Error {
public constructor(message?: string) {
@@ -47,8 +48,7 @@ export class ToDeviceKeyTransport
}
public constructor(
private userId: string,
private deviceId: string,
private membership: CallMembershipIdentityParts,
private roomId: string,
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
private statistics: Statistics,
@@ -74,7 +74,8 @@ export class ToDeviceKeyTransport
},
room_id: this.roomId,
member: {
claimed_device_id: this.deviceId,
claimed_device_id: this.membership.deviceId,
id: this.membership.memberId,
},
session: {
call_id: "",
@@ -92,7 +93,9 @@ export class ToDeviceKeyTransport
};
})
// filter out me
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
.filter(
(member) => !(member.userId == this.membership.userId && member.deviceId == this.membership.deviceId),
);
if (targets.length > 0) {
await this.client
@@ -127,12 +130,16 @@ export class ToDeviceKeyTransport
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
const hardcodedMemberIdAlternative = `${fromUser}:${content.member.claimed_device_id}`;
this.emit(
KeyTransportEvents.ReceivedKeys,
// TODO this is claimed information
fromUser,
// TODO: This is claimed information
content.member.claimed_device_id!,
// TODO userId this is claimed information, deviceId is claimed information
{
userId: fromUser,
deviceId: content.member.claimed_device_id!,
memberId: content.member.id ?? hardcodedMemberIdAlternative,
},
content.keys.key,
content.keys.index,
now,
-25
View File
@@ -1,25 +0,0 @@
/*
Copyright 2023 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.
*/
/**
* Information about a MatrixRTC conference focus. The only attribute that
* the js-sdk (currently) knows about is the type: applications can extend
* this class for different types of focus.
*/
export interface Focus {
type: string;
[key: string]: unknown;
}
+1 -2
View File
@@ -15,8 +15,7 @@ limitations under the License.
*/
export * from "./CallMembership.ts";
export type * from "./focus.ts";
export * from "./LivekitFocus.ts";
export * from "./LivekitTransport.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
+63 -10
View File
@@ -13,11 +13,12 @@ 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 { IMentions } from "../matrix.ts";
import type { IContent, IMentions } from "../matrix.ts";
import type { RelationEvent } from "../types.ts";
import type { CallMembership } from "./CallMembership.ts";
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
export type ParticipantId = string;
export type EncryptionKeyMapKey = string;
export interface EncryptionKeyEntry {
index: number;
@@ -37,8 +38,8 @@ export type ParticipantDeviceInfo = {
* A type representing the information needed to decrypt video streams.
*/
export type InboundEncryptionSession = {
key: Uint8Array;
participantId: ParticipantId;
key: Uint8Array<ArrayBuffer>;
membership: CallMembershipIdentityParts;
keyIndex: number;
creationTS: number;
};
@@ -47,7 +48,7 @@ export type InboundEncryptionSession = {
* The information about the key used to encrypt video streams.
*/
export type OutboundEncryptionSession = {
key: Uint8Array;
key: Uint8Array<ArrayBuffer>;
creationTS: number;
// The devices that this key is shared with.
sharedWith: Array<ParticipantDeviceInfo>;
@@ -68,6 +69,7 @@ export interface EncryptionKeysEventContent {
export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string };
member: {
id: string;
// 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;
@@ -96,10 +98,56 @@ export interface ICallNotifyContent {
}
export type RTCNotificationType = "ring" | "notification";
/**
* Represents the intention of the call from the perspective of the sending user.
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
*/
export type RTCCallIntent = "audio" | "video" | string;
/**
* This will check if the content has all the expected fields to be a valid IRTCNotificationContent.
* It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided.
* @param content
* @throws if the content is invalid
* @returns a parsed IRTCNotificationContent
*/
export function parseCallNotificationContent(content: IContent): IRTCNotificationContent {
if (content["m.mentions"] && typeof content["m.mentions"] !== "object") {
throw new Error("malformed m.mentions");
}
if (typeof content["notification_type"] !== "string") {
throw new Error("Missing or invalid notification_type");
}
if (typeof content["sender_ts"] !== "number") {
throw new Error("Missing or invalid sender_ts");
}
if (typeof content["lifetime"] !== "number") {
throw new Error("Missing or invalid lifetime");
}
if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") {
throw new Error("Invalid relation");
}
if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") {
throw new Error("Invalid m.call.intent");
}
const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"];
return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent;
}
/**
* Interface for `org.matrix.msc4075.rtc.notification` events.
* Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first.
*/
export interface IRTCNotificationContent extends RelationEvent {
"m.mentions": IMentions;
"decline_reason"?: string;
"m.mentions"?: IMentions;
"notification_type": RTCNotificationType;
/**
* The initial intent of the calling user.
*/
"m.call.intent"?: RTCCallIntent;
"sender_ts": number;
"lifetime": number;
}
@@ -113,11 +161,8 @@ export interface IRTCDeclineContent extends RelationEvent {}
export enum Status {
Disconnected = "Disconnected",
Connecting = "Connecting",
ConnectingFailed = "ConnectingFailed",
Connected = "Connected",
Reconnecting = "Reconnecting",
Disconnecting = "Disconnecting",
Stuck = "Stuck",
Unknown = "Unknown",
}
@@ -146,3 +191,11 @@ export type Statistics = {
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
m.sender === userId && m.deviceId === deviceId;
/**
* A RTC transport is a JSON object that describes how to connect to a RTC member.
*/
export interface Transport {
type: string;
[key: string]: unknown;
}
+9 -11
View File
@@ -14,14 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { InboundEncryptionSession, ParticipantId } from "./types.ts";
import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import { type InboundEncryptionSession, type EncryptionKeyMapKey } 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();
private tsBuffer: Map<EncryptionKeyMapKey, Map<number, number>> = new Map();
public constructor() {}
@@ -31,21 +32,18 @@ export class OutdatedKeyFilter {
* @param participantId
* @param item
*/
public isOutdated(participantId: ParticipantId, item: InboundEncryptionSession): boolean {
if (!this.tsBuffer.has(participantId)) {
this.tsBuffer.set(participantId, new Map<number, number>());
public isOutdated(membership: CallMembershipIdentityParts, item: InboundEncryptionSession): boolean {
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.tsBuffer.has(mapKey)) {
this.tsBuffer.set(mapKey, new Map<number, number>());
}
const latestTimestamp = this.tsBuffer.get(participantId)?.get(item.keyIndex);
const latestTimestamp = this.tsBuffer.get(mapKey)?.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);
this.tsBuffer.get(mapKey)!.set(item.keyIndex, item.creationTS);
return false;
}
}
export function getParticipantId(userId: string, deviceId: string): ParticipantId {
return `${userId}:${deviceId}`;
}
+10 -11
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import promiseRetry from "p-retry";
import { type MatrixClient } from "../client.ts";
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event.ts";
import { type Room } from "./room.ts";
@@ -31,7 +29,7 @@ import {
} from "../utils.ts";
import { MSC3089Branch } from "./MSC3089Branch.ts";
import { type ISendEventResponse } from "../@types/requests.ts";
import { type FileType } from "../http-api/index.ts";
import { type FileType, MatrixError } from "../http-api/index.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type RoomPowerLevelsEventContent, type SpaceChildEventContent } from "../@types/state_events.ts";
import type { EncryptedFile, FileContent } from "../@types/media.ts";
@@ -146,16 +144,17 @@ export class MSC3089TreeSpace {
await Promise.all(promises);
}
private retryInvite(userId: string): Promise<void> {
return simpleRetryOperation(async () => {
await this.client.invite(this.roomId, userId).catch((e) => {
private async retryInvite(userId: string): Promise<void> {
await simpleRetryOperation(
() => this.client.invite(this.roomId, userId),
(e) => {
// We don't want to retry permission errors forever...
if (e?.errcode === "M_FORBIDDEN") {
throw new promiseRetry.AbortError(e);
if (e instanceof MatrixError && e.errcode === "M_FORBIDDEN") {
return false;
}
throw e;
});
});
return true;
},
);
}
/**

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