Compare commits

..

74 Commits

Author SHA1 Message Date
RiotRobot 25555ec431 v40.0.0 2026-01-13 14:28:14 +00:00
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
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
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
91 changed files with 4271 additions and 2502 deletions
+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 }}
+2
View File
@@ -73,6 +73,8 @@ 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' &&
@@ -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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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
+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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
+3 -7
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
@@ -125,7 +123,7 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
+5 -4
View File
@@ -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,13 +75,14 @@ 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 }}
+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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version: "lts/*"
+3 -3
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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
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: |
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
operations-per-run: 250
days-before-issue-stale: -1
+66
View File
@@ -1,3 +1,69 @@
Changes in [40.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.0.0) (2026-01-13)
==================================================================================================
## 🚨 BREAKING CHANGES
* MatrixRTC Pseudonymous livekit identities ([#5110](https://github.com/matrix-org/matrix-js-sdk/pull/5110)). Contributed by @toger5.
## 🦖 Deprecations
* Mark `forwardingCurve25519KeyChain` as deprecated ([#5111](https://github.com/matrix-org/matrix-js-sdk/pull/5111)). Contributed by @richvdh.
* Mark `IEventDecryptionResult` as deprecated ([#5112](https://github.com/matrix-org/matrix-js-sdk/pull/5112)). Contributed by @richvdh.
## ✨ Features
* Implement MSC4387: M\_SAFETY error ([#5107](https://github.com/matrix-org/matrix-js-sdk/pull/5107)). Contributed by @Half-Shot.
* Implement \_unstable\_getRTCTransports for MSC4143 ([#5104](https://github.com/matrix-org/matrix-js-sdk/pull/5104)). Contributed by @Half-Shot.
* Use `membershipID` for session events ([#5105](https://github.com/matrix-org/matrix-js-sdk/pull/5105)). Contributed by @toger5.
## 🐛 Bug Fixes
* Make MatrixRTC encryption key types narrower for TS 5.9 compatibility ([#5117](https://github.com/matrix-org/matrix-js-sdk/pull/5117)). Contributed by @robintown.
* Re-check outgoing requests after processing them ([#5109](https://github.com/matrix-org/matrix-js-sdk/pull/5109)). Contributed by @andybalaam.
* Make token refresher init itself lazily ([#5106](https://github.com/matrix-org/matrix-js-sdk/pull/5106)). Contributed by @dbkr.
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
+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
) {
// ...
}
+2
View File
@@ -34,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`
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "39.1.0",
"version": "40.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -49,17 +49,17 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.3.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": "7",
"sdp-transform": "^2.14.1",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "13"
},
@@ -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",
@@ -101,21 +101,21 @@
"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", () => {
+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();
+3 -1
View File
@@ -305,7 +305,9 @@ export function encryptMegolmEventRawPlainText(opts: {
},
type: "m.room.encrypted",
unsigned: {},
state_key: opts.plaintext.state_key ? `${opts.plaintext.type}:${opts.plaintext.state_key}` : undefined,
state_key: opts.plaintext.hasOwnProperty("state_key")
? `${opts.plaintext.type}:${opts.plaintext.state_key}`
: undefined,
};
}
@@ -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 () => {
+1 -1
View File
@@ -82,7 +82,7 @@ export function getSyncResponse(
state_key: "",
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc3414.encrypt_state_events": encryptStateEvents,
"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) {
+176 -4
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 () => {
@@ -1077,21 +1082,169 @@ describe("MatrixClient", function () {
});
});
it("can update delayed events", async () => {
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";
const action = UpdateDelayedEventAction.Restart;
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
},
];
await client._unstable_cancelScheduledDelayedEvent(delayId);
});
it("can restart scheduled delayed events (action in request path)", async () => {
const delayId = "id";
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);
});
});
@@ -3783,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",
},
]);
});
});
});
+43 -36
View File
@@ -28,9 +28,16 @@ function makeMockEvent(originTs = 0): MatrixEvent {
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(() => {
@@ -53,29 +60,29 @@ describe("CallMembership", () => {
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 }),
);
@@ -85,27 +92,27 @@ 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, { ...membershipTemplate, foci_preferred: [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 = new CallMembership(makeMockEvent(), membershipTemplate);
const oldestMembership = createCallMembership(makeMockEvent(), membershipTemplate);
it("gets the correct active transport with oldest_membership", () => {
const membership = new CallMembership(makeMockEvent(), {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
@@ -119,7 +126,7 @@ describe("CallMembership", () => {
});
it("gets the correct active transport with multi_sfu", () => {
const membership = new CallMembership(makeMockEvent(), {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
@@ -132,7 +139,7 @@ describe("CallMembership", () => {
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
});
it("does not provide focus if the selection method is unknown", () => {
const membership = new CallMembership(makeMockEvent(), {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "unknown" },
@@ -143,7 +150,7 @@ describe("CallMembership", () => {
});
});
describe("correct values from computed fields", () => {
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
@@ -170,7 +177,7 @@ describe("CallMembership", () => {
expect(membership.scope).toBe("m.room");
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("0");
expect(membership.membershipID).toBe("@alice:example.org:AAAAAAA");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
@@ -192,29 +199,29 @@ describe("CallMembership", () => {
it("rejects membership with no slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.toThrow();
});
it("rejects membership with no application", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
createCallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
}).toThrow();
});
it("rejects membership with incorrect application", () => {
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: { wrong_type_key: "unknown" },
});
@@ -223,34 +230,34 @@ describe("CallMembership", () => {
it("rejects membership with no member", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
}).toThrow();
});
it("rejects membership with incorrect member", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@@test" },
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
});
@@ -258,41 +265,41 @@ describe("CallMembership", () => {
});
it("rejects membership with incorrect sticky_key", () => {
expect(() => {
new CallMembership(makeMockEvent(), membershipTemplate);
createCallMembership(makeMockEvent(), membershipTemplate);
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: 1,
msc4354_sticky_key: undefined,
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: "1",
msc4354_sticky_key: undefined,
});
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
createCallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: 1,
sticky_key: "valid",
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid",
sticky_key: "valid",
});
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid_but_different",
sticky_key: "valid",
@@ -310,11 +317,11 @@ describe("CallMembership", () => {
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
const oldestMembership = new CallMembership(makeMockEvent(), {
const oldestMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
rtc_transports: [{ type: "oldest_transport" }],
});
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
@@ -324,7 +331,7 @@ describe("CallMembership", () => {
});
});
describe("correct values from computed fields", () => {
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
@@ -372,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();
});
+178 -102
View File
@@ -34,10 +34,13 @@ import {
type MembershipData,
mockRoomState,
mockRTCEvent,
owmMemberIdentity,
} from "./mocks";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts";
import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
const mockFocus = { type: "mock" };
@@ -92,27 +95,32 @@ describe("MatrixRTCSession", () => {
testCreateSticky: true,
},
])(
"roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
"roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
(testConfig) => {
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => {
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, async () => {
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
mockRoom.getLiveTimeline.mockReturnValue({
getState: jest.fn().mockReturnValue(undefined),
} as unknown as EventTimeline);
const warnLogSpy = jest.spyOn(console, "warn");
warnLogSpy.mockClear();
const stateWarningWasLogged = () =>
warnLogSpy.mock.calls.find((call) => (call[1] as string).includes("Couldn't get state for room"));
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
await flushPromises();
if (testConfig.listenForMemberStateEvents) {
// eslint-disable-next-line jest/no-conditional-expect
expect(() => {
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
}).toThrow();
expect(stateWarningWasLogged()).toBeTruthy();
} else {
// eslint-disable-next-line jest/no-conditional-expect
expect(() => {
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
}).not.toThrow();
expect(stateWarningWasLogged()).toBeFalsy();
}
});
it("creates a room-scoped session from room state", () => {
it("creates a room-scoped session from room state", async () => {
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
sess = MatrixRTCSession.sessionForSlot(
@@ -121,6 +129,7 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].slotDescription.id).toEqual("");
expect(sess?.memberships[0].scope).toEqual("m.room");
@@ -159,7 +168,7 @@ describe("MatrixRTCSession", () => {
expect(sess?.memberships).toHaveLength(0);
});
it("ignores expired memberships events", () => {
it("ignores expired memberships events", async () => {
jest.useFakeTimers();
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.expires = 1000;
@@ -173,6 +182,7 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
jest.useRealTimers();
@@ -203,7 +213,7 @@ describe("MatrixRTCSession", () => {
expect(sess?.memberships.length).toEqual(0);
});
it("honours created_ts", () => {
it("honours created_ts", async () => {
jest.useFakeTimers();
jest.setSystemTime(500);
const expiredMembership = Object.assign({}, membershipTemplate);
@@ -216,6 +226,7 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
jest.useRealTimers();
});
@@ -331,8 +342,8 @@ describe("MatrixRTCSession", () => {
},
);
describe("roomSessionForRoom combined state", () => {
it("perfers sticky events when both membership and sticky events appear for the same user", () => {
describe("roomsessionForSlot combined state", () => {
it("perfers sticky events when both membership and sticky events appear for the same user", async () => {
// Create a room with identical member state and sticky state for the same user.
const mockRoom = makeMockRoom([membershipTemplate]);
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
@@ -351,6 +362,7 @@ describe("MatrixRTCSession", () => {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
});
await flushPromises();
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].slotDescription.id).toEqual("");
expect(sess?.memberships[0].scope).toEqual("m.room");
@@ -359,7 +371,7 @@ describe("MatrixRTCSession", () => {
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.slotDescription.id).toEqual("");
});
it("combines sticky and membership events when both exist", () => {
it("combines sticky and membership events when both exist", async () => {
// Create a room with identical member state and sticky state for the same user.
const mockRoom = makeMockRoom([membershipTemplate]);
const stickyUserId = "@stickyev:user.example";
@@ -381,6 +393,7 @@ describe("MatrixRTCSession", () => {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
});
await flushPromises();
const memberships = sess.memberships;
expect(memberships.length).toEqual(2);
@@ -396,7 +409,7 @@ describe("MatrixRTCSession", () => {
expect(sess?.slotDescription.id).toEqual("");
});
it("handles an incoming sticky event to an existing session", () => {
it("handles an incoming sticky event to an existing session", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const stickyUserId = "@stickyev:user.example";
@@ -404,6 +417,7 @@ describe("MatrixRTCSession", () => {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
});
await flushPromises();
expect(sess.memberships.length).toEqual(1);
const stickyEv = mockRTCEvent(
{
@@ -419,12 +433,13 @@ describe("MatrixRTCSession", () => {
return [stickyEv];
});
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
await flushPromises();
expect(sess.memberships.length).toEqual(2);
});
});
describe("getOldestMembership", () => {
it("returns the oldest membership event", () => {
it("returns the oldest membership event", async () => {
jest.useFakeTimers();
jest.setSystemTime(4000);
const mockRoom = makeMockRoom([
@@ -433,7 +448,8 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
jest.useRealTimers();
});
@@ -446,7 +462,7 @@ describe("MatrixRTCSession", () => {
[undefined, "audio", "audio"],
["audio", "audio", "audio"],
["audio", "video", undefined],
])("gets correct consensus for %s + %s = %s", (intentA, intentB, result) => {
])("gets correct consensus for %s + %s = %s", async (intentA, intentB, result) => {
jest.useFakeTimers();
jest.setSystemTime(4000);
const mockRoom = makeMockRoom([
@@ -454,7 +470,8 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { "m.call.intent": intentB }),
]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
expect(sess.getConsensusCallIntent()).toEqual(result);
jest.useRealTimers();
});
@@ -479,9 +496,9 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
sess.joinRTCSession(owmMemberIdentity, [{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "oldest_membership",
});
@@ -498,9 +515,9 @@ describe("MatrixRTCSession", () => {
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
sess.joinRTCSession(owmMemberIdentity, [{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "unknown",
});
@@ -514,7 +531,7 @@ describe("MatrixRTCSession", () => {
let sendStateEventMock: jest.Mock;
let sentStateEvent: Promise<void>;
beforeEach(() => {
beforeEach(async () => {
sentStateEvent = new Promise((resolve) => {
sendStateEventMock = jest.fn(resolve);
});
@@ -523,9 +540,13 @@ describe("MatrixRTCSession", () => {
client.sendEvent = sendEventMock;
client._unstable_updateDelayedEvent = jest.fn();
client._unstable_cancelScheduledDelayedEvent = jest.fn();
client._unstable_restartScheduledDelayedEvent = jest.fn();
client._unstable_sendScheduledDelayedEvent = jest.fn();
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
});
afterEach(async () => {
@@ -542,12 +563,12 @@ describe("MatrixRTCSession", () => {
});
it("shows joined once join is called", () => {
sess!.joinRoomSession([mockFocus], mockFocus);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus);
expect(sess!.isJoined()).toEqual(true);
});
it("uses the sticky events membership manager implementation", () => {
sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { unstableSendStickyEvents: true });
expect(sess!.isJoined()).toEqual(true);
expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true);
});
@@ -565,10 +586,10 @@ describe("MatrixRTCSession", () => {
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
});
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
@@ -626,7 +647,10 @@ describe("MatrixRTCSession", () => {
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
});
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring", callIntent: "audio" });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
notificationType: "ring",
callIntent: "audio",
});
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [
@@ -638,7 +662,7 @@ describe("MatrixRTCSession", () => {
},
]);
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
expect(sess!.getConsensusCallIntent()).toEqual("audio");
@@ -689,27 +713,27 @@ describe("MatrixRTCSession", () => {
it("doesn't send a notification when joining an existing call", async () => {
// Add another member to the call so that it is considered an existing call
mockRoomState(mockRoom, [membershipTemplate]);
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
expect(client.sendEvent).not.toHaveBeenCalled();
});
it("doesn't send a notification when someone else starts the call faster than us", async () => {
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
// But this time we want to simulate a race condition in which we receive a state event
// from someone else, starting the call before our own state event has been sent
mockRoomState(mockRoom, [membershipTemplate]);
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
// We assume that the responsibility to send a notification, if any, lies with the other
// participant that won the race
@@ -718,26 +742,26 @@ describe("MatrixRTCSession", () => {
});
describe("onMembershipsChanged", () => {
it("does not emit if no membership changes", () => {
it("does not emit if no membership changes", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
expect(onMembershipsChanged).not.toHaveBeenCalled();
});
it("emits on membership changes", () => {
it("emits on membership changes", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
mockRoomState(mockRoom, []);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
expect(onMembershipsChanged).toHaveBeenCalled();
});
@@ -750,7 +774,7 @@ describe("MatrixRTCSession", () => {
// const membership = Object.assign({}, membershipTemplate);
// const mockRoom = makeMockRoom([membership]);
// sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
// sess = MatrixRTCSession.roomsessionForSlot(client, mockRoom);
// const membershipObject = sess.memberships[0];
// const onMembershipsChanged = jest.fn();
@@ -786,7 +810,8 @@ describe("MatrixRTCSession", () => {
client.encryptAndSendToDevice = sendToDeviceMock;
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
// await new Promise((resolve) => sess!.once(MatrixRTCSessionEvent.MembershipsChanged, resolve));
});
afterEach(async () => {
@@ -795,7 +820,7 @@ describe("MatrixRTCSession", () => {
});
it("creates a key when joining", () => {
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess?.reemitEncryptionKeys();
@@ -803,6 +828,11 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
expect.any(Uint8Array),
0,
{
deviceId: "AAAAAAA",
memberId: "@alice:example.org:AAAAAAA",
userId: "@alice:example.org",
},
"@alice:example.org:AAAAAAA",
);
});
@@ -814,7 +844,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await eventSentPromise;
@@ -839,13 +869,13 @@ describe("MatrixRTCSession", () => {
sendStateEventMock = jest.fn(resolve);
});
client.sendStateEvent = sendStateEventMock;
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await sentStateEvent;
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(client.sendEvent).toHaveBeenCalledTimes(1);
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(client.sendEvent).toHaveBeenCalledTimes(1);
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
@@ -869,7 +899,7 @@ describe("MatrixRTCSession", () => {
});
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
// wait for the encryption event to get sent
await jest.advanceTimersByTimeAsync(5000);
await eventSentPromise;
@@ -892,7 +922,7 @@ describe("MatrixRTCSession", () => {
throw e;
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
});
@@ -905,13 +935,13 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});
const mockRoom = makeMockRoom([membershipTemplate, member2]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
// joining will trigger an initial key send
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
sess.joinRoomSession([mockFocus], mockFocus, {
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
manageMediaKeys: true,
updateEncryptionKeyThrottle: 1000,
makeKeyDelay: 3000,
@@ -921,14 +951,14 @@ describe("MatrixRTCSession", () => {
// member2 leaves triggering key rotation
mockRoomState(mockRoom, [membershipTemplate]);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
// member2 re-joins which should trigger an immediate re-send
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
// but, that immediate resend is throttled so we need to wait a bit
jest.advanceTimersByTime(1000);
const { keys } = await keysSentPromise2;
@@ -954,13 +984,13 @@ describe("MatrixRTCSession", () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
@@ -979,7 +1009,7 @@ describe("MatrixRTCSession", () => {
});
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
await keysSentPromise2;
@@ -1005,8 +1035,8 @@ describe("MatrixRTCSession", () => {
const mockRoom = makeMockRoom([member1, member2]);
mockRoomState(mockRoom, [member1, member2]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -1026,7 +1056,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockClear();
// these should be a no-op:
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
expect(sendEventMock).toHaveBeenCalledTimes(0);
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
} finally {
@@ -1050,8 +1080,9 @@ describe("MatrixRTCSession", () => {
};
const mockRoom = makeMockRoom([member1, member2]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -1071,7 +1102,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockClear();
// this should be a no-op:
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
expect(sendEventMock).toHaveBeenCalledTimes(0);
// advance time to avoid key throttling
@@ -1086,7 +1117,7 @@ describe("MatrixRTCSession", () => {
});
// this should re-send the key
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
await keysSentPromise2;
@@ -1114,13 +1145,16 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});
const mockRoom = makeMockRoom([membershipTemplate, member2]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
const onMyEncryptionKeyChanged = jest.fn();
sess.on(
MatrixRTCSessionEvent.EncryptionKeyChanged,
(_key: Uint8Array, _idx: number, participantId: string) => {
if (participantId === `${client.getUserId()}:${client.getDeviceId()}`) {
(_key: Uint8Array, _idx: number, membership: CallMembershipIdentityParts) => {
if (
membership.userId === client.getUserId() &&
membership.deviceId === client.getDeviceId()
) {
onMyEncryptionKeyChanged();
}
},
@@ -1130,7 +1164,10 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
manageMediaKeys: true,
makeKeyDelay: KEY_DELAY,
});
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
const firstKeysPayload = await keysSentPromise1;
expect(firstKeysPayload.keys).toHaveLength(1);
@@ -1144,7 +1181,7 @@ describe("MatrixRTCSession", () => {
});
mockRoomState(mockRoom, [membershipTemplate]);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
jest.advanceTimersByTime(KEY_DELAY);
expect(sendKeySpy).toHaveBeenCalledTimes(1);
@@ -1204,14 +1241,15 @@ describe("MatrixRTCSession", () => {
if (i === 0) {
// if first time around then set up the session
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
} else {
// otherwise update the state reducing the membership each time in order to trigger key rotation
mockRoomState(mockRoom, members.slice(0, membersToTest - i));
}
sess!.onRTCSessionMemberUpdate();
await sess!._onRTCSessionMemberUpdate();
// advance time to avoid key throttling
jest.advanceTimersByTime(10000);
@@ -1230,13 +1268,14 @@ describe("MatrixRTCSession", () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
sendEventMock.mockClear();
@@ -1250,7 +1289,7 @@ describe("MatrixRTCSession", () => {
});
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
await new Promise((resolve) => {
realSetTimeout(resolve);
@@ -1271,13 +1310,13 @@ describe("MatrixRTCSession", () => {
});
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, {
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
manageMediaKeys: true,
useExperimentalToDeviceTransport: true,
});
sess.onRTCSessionMemberUpdate();
await sess._onRTCSessionMemberUpdate();
await keySentPromise;
@@ -1294,8 +1333,9 @@ describe("MatrixRTCSession", () => {
describe("receiving", () => {
it("collects keys from encryption events", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
@@ -1312,6 +1352,11 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
@@ -1319,8 +1364,9 @@ describe("MatrixRTCSession", () => {
it("collects keys at non-zero indices", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
@@ -1337,6 +1383,11 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
4,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
@@ -1345,8 +1396,8 @@ describe("MatrixRTCSession", () => {
it("collects keys by merging", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
@@ -1363,6 +1414,11 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
@@ -1383,11 +1439,21 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
4,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
@@ -1396,8 +1462,8 @@ describe("MatrixRTCSession", () => {
it("ignores older keys at same index", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
@@ -1447,6 +1513,11 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("newer key"),
0,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
@@ -1455,8 +1526,8 @@ describe("MatrixRTCSession", () => {
it("key timestamps are treated as monotonic", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
@@ -1493,15 +1564,20 @@ describe("MatrixRTCSession", () => {
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("second key"),
0,
{
deviceId: "bobsphone",
memberId: "@bob:example.org:bobsphone",
userId: "@bob:example.org",
},
"@bob:example.org:bobsphone",
);
});
it("ignores keys event for the local participant", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", {
device_id: client.getDeviceId(),
@@ -1522,11 +1598,11 @@ describe("MatrixRTCSession", () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
// defaults to getTs()
jest.setSystemTime(1000);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
@@ -1578,10 +1654,10 @@ describe("MatrixRTCSession", () => {
describe("read status", () => {
it("returns the correct probablyLeft status", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
expect(sess!.probablyLeft).toBe(undefined);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
expect(sess!.probablyLeft).toBe(false);
// Simulate the membership manager believing the user has left
@@ -1592,12 +1668,12 @@ describe("MatrixRTCSession", () => {
expect(sess!.probablyLeft).toBe(true);
});
it("returns membershipStatus once joinRoomSession got called", () => {
it("returns membershipStatus once joinRTCSession got called", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
expect(sess!.membershipStatus).toBe(undefined);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
expect(sess!.membershipStatus).toBe(Status.Connecting);
});
});
@@ -14,18 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
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.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
"MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void {
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)!;
@@ -35,6 +35,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
await flushPromises();
}
beforeEach(() => {
@@ -47,7 +48,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
client.matrixRTC.stop();
});
it("Fires event when session starts", () => {
it("Fires event when session starts", async () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
@@ -56,6 +57,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
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);
@@ -77,7 +79,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
}
});
it("Fires event when session ends", () => {
it("Fires event when session ends", async () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
@@ -85,13 +87,13 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
sendLeaveMembership(room1, membershipData);
await flushPromises();
await sendLeaveMembership(room1, membershipData);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Fires correctly with custom sessionDescription", () => {
it("Fires correctly with custom sessionDescription", async () => {
const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
@@ -111,6 +113,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
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();
@@ -121,18 +124,19 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
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();
// Stop room1's RTC session. Tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room2);
sendLeaveMembership(room2, room2MembershipData);
await sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// Stop room1's RTC session. Not tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room1);
sendLeaveMembership(room1, room1MembershipData);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
@@ -140,7 +144,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
}
});
it("Doesn't fire event if unrelated sessions ends", () => {
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" }];
@@ -150,7 +154,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
client.emit(ClientEvent.Room, room1);
sendLeaveMembership(room1, membership);
await sendLeaveMembership(room1, membership);
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
+64 -41
View File
@@ -94,6 +94,9 @@ 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._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" });
@@ -122,7 +125,9 @@ 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, callSession);
@@ -138,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 },
@@ -157,13 +163,13 @@ describe("MembershipManager", () => {
it("reschedules delayed leave event if sending state cancels it", async () => {
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)
@@ -179,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({
@@ -258,6 +264,7 @@ describe("MembershipManager", () => {
expires: 14400000,
device_id: "AAAAAAA",
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
} satisfies SessionMembershipData,
userStateKey,
@@ -265,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 () => {
@@ -343,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" }),
);
@@ -378,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",
@@ -404,17 +412,17 @@ describe("MembershipManager", () => {
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, 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",
@@ -438,6 +446,9 @@ 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("does nothing if own membership still present", async () => {
const manager = new MembershipManager({}, room, client, callSession);
@@ -447,6 +458,9 @@ describe("MembershipManager", () => {
// 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([
@@ -462,6 +476,9 @@ 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, callSession);
@@ -469,7 +486,7 @@ describe("MembershipManager", () => {
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:
@@ -478,7 +495,7 @@ 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 () => {
@@ -487,10 +504,10 @@ describe("MembershipManager", () => {
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" }),
);
@@ -503,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);
});
});
@@ -523,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 });
}
});
@@ -681,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,
@@ -695,17 +712,17 @@ describe("MembershipManager", () => {
// 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);
});
});
@@ -734,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,
@@ -808,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);
@@ -822,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
@@ -898,10 +915,16 @@ describe("MembershipManager", () => {
describe("join()", () => {
describe("sends an rtc membership event", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
const updateDelayedEventHandle = createAsyncHandle<void>(
client._unstable_updateDelayedEvent as Mock,
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
const memberManager = new StickyEventMembershipManager(
undefined,
room,
client,
callSession,
"@alice:example.org:AAAAAAA_m.call",
);
const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession);
memberManager.join([], focus);
@@ -916,16 +939,16 @@ describe("MembershipManager", () => {
application: { type: "m.call" },
member: {
user_id: "@alice:example.org",
id: "_@alice:example.org_AAAAAAA_m.call",
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",
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
);
updateDelayedEventHandle.resolve?.();
restartScheduledDelayedEventHandle.resolve?.();
// Ensure we have sent the delayed disconnect event.
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
@@ -935,7 +958,7 @@ describe("MembershipManager", () => {
null,
"org.matrix.msc4143.rtc.member",
{
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
);
// ..once
@@ -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),
};
}
+296 -79
View File
@@ -23,7 +23,8 @@ import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { logger } from "../../../src/logger.ts";
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
describe("RTCEncryptionManager", () => {
// The manager being tested
@@ -32,6 +33,7 @@ describe("RTCEncryptionManager", () => {
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: jest.Mock;
let rtcIdentifierProvider: jest.Mock;
beforeEach(() => {
statistics = {
@@ -53,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,
);
});
@@ -76,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();
});
@@ -90,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(
@@ -112,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(
@@ -143,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?)
@@ -170,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);
@@ -192,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();
@@ -220,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);
@@ -242,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();
@@ -272,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);
@@ -287,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(
@@ -314,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);
@@ -328,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) {
@@ -364,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);
@@ -394,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);
@@ -417,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);
@@ -441,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",
);
@@ -450,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);
@@ -477,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,
@@ -489,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 });
@@ -498,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 */,
@@ -513,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);
@@ -525,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 */,
@@ -552,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);
@@ -573,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
@@ -581,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,
@@ -597,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,
@@ -611,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",
);
});
@@ -619,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);
@@ -633,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,
@@ -642,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,
@@ -651,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,
@@ -661,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(
@@ -671,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(
@@ -687,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(
@@ -697,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();
});
});
@@ -706,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);
@@ -721,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
@@ -734,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
@@ -742,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();
@@ -757,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",
);
@@ -780,10 +853,153 @@ describe("RTCEncryptionManager", () => {
);
});
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
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();
await flushPromises();
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>",
);
});
it("Should use legacy participant id if not using sticky event", async () => {
getMembershipMock.mockReturnValue([]);
encryptionManager.join({
manageMediaKeys: true,
unstableSendStickyEvents: false,
});
encryptionManager.onMembershipsUpdate();
await flushPromises();
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
{
deviceId: "DEVICE01",
userId: "@alice:example.org",
memberId: "@alice:example.org:DEVICE01",
},
"@alice:example.org:DEVICE01",
);
});
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>;
encryptionManager = new RTCEncryptionManager(
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
);
getMembershipMock.mockReturnValue([]);
encryptionManager.join({
manageMediaKeys: true,
unstableSendStickyEvents: true,
});
encryptionManager.onMembershipsUpdate();
await flushPromises();
// 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 */,
);
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,
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}`,
);
}
});
@@ -800,6 +1016,7 @@ function expectKeyAtIndexToHaveBeenSentTo(
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
);
}
function expectKeyAtIndexNotToHaveBeenSentTo(
mockTransport: Mocked<ToDeviceKeyTransport>,
index: number,
@@ -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();
+24 -3
View File
@@ -18,11 +18,19 @@ 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: "",
@@ -52,6 +60,9 @@ export type MockClient = Pick<
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
@@ -67,6 +78,9 @@ 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(),
@@ -174,8 +188,15 @@ export function mockRTCEvent(
} 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 } {
+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 });
+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");
@@ -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`] = `
{
+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;
+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>;
}
}
+6 -6
View File
@@ -298,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 {
+1 -1
View File
@@ -106,7 +106,7 @@ export interface RoomPinnedEventsEventContent {
export interface RoomEncryptionEventContent {
"algorithm": "m.megolm.v1.aes-sha2";
"io.element.msc3414.encrypt_state_events"?: boolean;
"io.element.msc4362.encrypt_state_events"?: boolean;
"rotation_period_ms"?: number;
"rotation_period_msgs"?: number;
}
+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" });
}
+281 -50
View File
@@ -18,8 +18,6 @@ limitations under the License.
* This is an internal module. See {@link MatrixClient} for the public class.
*/
import { type Optional } from "matrix-events-sdk";
import type { IDeviceKeys, IOneTimeKey } from "./@types/crypto.ts";
import { type ISyncStateData, type SetPresence, SyncApi, type SyncApiOptions, SyncState } from "./sync.ts";
import {
@@ -106,6 +104,7 @@ import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-m
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import {
isSendDelayedEventRequestOpts,
UpdateDelayedEventAction,
type DelayedEventInfo,
type IAddThreePidOnlyBody,
type IBindThreePidBody,
@@ -130,7 +129,6 @@ import {
type KnockRoomOpts,
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
type UpdateDelayedEventAction,
} from "./@types/requests.ts";
import {
type AccountDataEvents,
@@ -248,6 +246,7 @@ import {
} from "./oidc/index.ts";
import { type EmptyObject } from "./@types/common.ts";
import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts";
import { type Transport } from "./matrixrtc/index.ts";
export type Store = IStore;
@@ -1218,7 +1217,26 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
private readonly enableEncryptedStateEvents: boolean;
/**
* Support MSC4362: Simplified Encrypted State Events.
*
* The client must be recreated for changes to this setting to take effect
* reliably.
*
* When this setting is true, if we find a state event that is encrypted
* (within a room that supports encrypted state), we will attempt to decrypt
* it as specified in MSC4362. If the user was in the room at the time an
* encrypted state event was received (meaning we have the key), even if
* this setting was set to false at the time it was received, recreating the
* client with this setting set to true will allow decrypting that event.
*
* When this setting is false, any state event that is encrypted will not be
* decrypted, meaning it will have no effect. This matched the behaviour of
* a client that does not support MSC4362.
*/
public enableEncryptedStateEvents: boolean;
public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code.
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
public groupCallEventHandler?: GroupCallEventHandler;
@@ -2408,7 +2426,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const roomId = res.room_id;
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
// Try to accept the room key bundle specified in a `m.room_key_bundle` to-device message we (might have) already received.
const bundleDownloaded = await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
// If this fails, i.e. we haven't received this message yet, we need to wait until the to-device message arrives.
if (!bundleDownloaded) {
this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
}
}
// In case we were originally given an alias, check the room cache again
@@ -2458,8 +2481,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param event - The event to resend.
* @param room - Optional. The room the event is in. Will update the
* timeline entry if provided.
* @returns Promise which resolves: to an ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> {
// also kick the to-device queue to retry
@@ -2498,6 +2521,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: TODO
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public setRoomName(roomId: string, name: string): Promise<ISendEventResponse> {
return this.sendStateEvent(roomId, EventType.RoomName, { name: name });
@@ -2509,6 +2534,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param htmlTopic - Optional.
* @returns Promise which resolves: TODO
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public setRoomTopic(roomId: string, topic?: string, htmlTopic?: string): Promise<ISendEventResponse> {
const content = ContentHelpers.makeTopicContent(topic, htmlTopic);
@@ -2580,6 +2607,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param powerLevel - the numeric power level to update given users to
* @returns Promise which resolves: to an ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public async setPowerLevel(
roomId: string,
@@ -2625,6 +2654,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Create an m.beacon_info event
* @returns
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async unstable_createLiveBeacon(
@@ -2639,6 +2670,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* using a specific m.beacon_info.* event variable type
* @param roomId - string
* @returns
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async unstable_setLiveBeacon(
@@ -2648,6 +2681,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
}
/**
* Send a Matrix timeline event.
* @param roomId The room to send to.
* @param eventType The event type.
* @param content The event content.
* @param txnId An optional ID to deduplicate requests in case of repeated attempts.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendEvent<K extends keyof TimelineEvents>(
roomId: string,
eventType: K,
@@ -3077,6 +3119,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Rejects: with an error response.
* @throws Error if called with `with_rel_types` (MSC3912) but the server does not support it.
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public redactEvent(
roomId: string,
@@ -3223,6 +3267,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param txnId - Optional.
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendEmoteMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
public sendEmoteMessage(
@@ -3249,6 +3295,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendImageMessage(roomId: string, url: string, info?: ImageInfo, text?: string): Promise<ISendEventResponse>;
public sendImageMessage(
@@ -3283,6 +3331,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendStickerMessage(
roomId: string,
@@ -3322,6 +3372,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendHtmlMessage(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
public sendHtmlMessage(
@@ -3348,6 +3400,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendHtmlNotice(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
public sendHtmlNotice(
@@ -3374,6 +3428,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendHtmlEmote(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
public sendHtmlEmote(
@@ -3402,6 +3458,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line
public async _unstable_sendDelayedEvent<K extends keyof TimelineEvents>(
@@ -3435,6 +3493,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) and
* [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line
public async _unstable_sendStickyDelayedEvent<K extends keyof TimelineEvents>(
@@ -3443,7 +3503,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
delayOpts: SendDelayedEventRequestOpts,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
content: TimelineEvents[K] & { msc4354_sticky_key?: string },
txnId?: string,
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
@@ -3475,6 +3535,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line
public async _unstable_sendDelayedStateEvent<K extends keyof StateEvents>(
@@ -3509,6 +3571,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line
public async _unstable_sendStickyEvent<K extends keyof TimelineEvents>(
@@ -3516,7 +3580,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
stickDuration: number,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
content: TimelineEvents[K] & { msc4354_sticky_key?: string },
txnId?: string,
): Promise<ISendEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
@@ -3570,8 +3634,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @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,
@@ -3583,17 +3652,125 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
"updateDelayedEvent",
);
}
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
}
/**
* Cancel the scheduled delivery of the delayed event matching the provided delayId.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_cancelScheduledDelayedEvent(
delayId: string,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Cancel, requestOptions);
}
/**
* Restart the scheduled delivery of the delayed event matching the given delayId.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_restartScheduledDelayedEvent(
delayId: string,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Restart, requestOptions);
}
/**
* Immediately send the delayed event matching the given delayId,
* instead of waiting for its scheduled delivery.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_sendScheduledDelayedEvent(
delayId: string,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions);
}
private async updateScheduledDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
`${action}ScheduledDelayedEvent`,
);
}
try {
const path = utils.encodeUri("/delayed_events/$delayId/$action", {
$delayId: delayId,
$action: action,
});
return await this.http.request(Method.Post, path, undefined, undefined, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
// For backwards compatibility with an older version of this endpoint
// which put the update action in the request body instead of the path
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
} else {
throw e;
}
}
}
/**
* @deprecated Present for backwards compatibility with an older version of MSC4140
* which had a single, authenticated endpoint for updating a delayed event, instead
* of one unauthenticated endpoint per update action.
*/
private async updateScheduledDelayedEventWithActionInBody(
delayId: string,
action: UpdateDelayedEventAction,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
const path = utils.encodeUri("/delayed_events/$delayId", {
$delayId: delayId,
});
const data = {
action,
};
return await this.http.authedRequest(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
try {
return await this.http.request(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_MISSING_TOKEN") {
// For backwards compatibility with an older version of this endpoint
// which required authentication
return await this.http.authedRequest(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
} else {
throw e;
}
}
}
/**
@@ -3706,6 +3883,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
}
/**
*
* @param roomId
* @param notificationEventId
* @returns
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public sendRtcDecline(roomId: string, notificationEventId: string): Promise<ISendEventResponse> {
return this.sendEvent(roomId, EventType.RTCDecline, {
"m.relates_to": { event_id: notificationEventId, rel_type: RelationType.Reference },
@@ -3885,6 +4070,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param opts - Optional reason object. For backwards compatibility, a string is also accepted, and will be interpreted as a reason.
*
* @returns An empty object.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
if (typeof opts != "object") {
@@ -3904,6 +4091,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param email - The email address to invite.
* @returns Promise which resolves: `{}` an empty object.
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public inviteByEmail(roomId: string, email: string): Promise<EmptyObject> {
return this.inviteByThreePid(roomId, "email", email);
@@ -3916,6 +4105,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param address - The address for the specified medium.
* @returns Promise which resolves: `{}` an empty object.
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<EmptyObject> {
const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId });
@@ -4119,6 +4310,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param data - The JSON object to set.
* @returns
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
// eslint-disable-next-line camelcase
public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<EmptyObject>;
@@ -4134,6 +4327,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: `{}` an empty object.
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public async setDisplayName(name: string): Promise<EmptyObject> {
const prom = await this.setProfileInfo("displayname", { displayname: name });
@@ -4149,6 +4344,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: `{}` an empty object.
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public async setAvatarUrl(url: string): Promise<EmptyObject> {
const prom = await this.setProfileInfo("avatar_url", { avatar_url: url });
@@ -4336,6 +4533,46 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return eventMapperFor(this, options || {});
}
/**
* Calls the `/context` API for the given room ID & event ID.
* Returns the response, with `event` asserted and all optional arrays defaulted to an empty array.
* @param roomId - the room ID to request a context for
* @param eventId - the event ID to request a context for
* @throws if `event` in the response is missing
* @private
*/
private async getEventContext(
roomId: string,
eventId: string,
): Promise<IContextResponse & Omit<Required<IContextResponse>, "start" | "end">> {
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
$roomId: roomId,
$eventId: eventId,
});
const params: Record<string, string | string[]> = {
limit: "0",
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
if (res.event) {
return {
start: res.start,
end: res.end,
event: res.event,
events_after: res.events_after ?? [],
events_before: res.events_before ?? [],
state: res.state ?? [],
};
}
throw new Error("'event' not in '/context' result - homeserver too old?");
}
/**
* Get an EventTimeline for the given event
*
@@ -4350,7 +4587,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves:
* {@link EventTimeline} including the given event
*/
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<Optional<EventTimeline>> {
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | null> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error(
@@ -4368,24 +4605,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (timelineSet.thread && this.supportsThreads()) {
return this.getThreadTimeline(timelineSet, eventId);
return (await this.getThreadTimeline(timelineSet, eventId)) ?? null;
}
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
$eventId: eventId,
});
let params: Record<string, string | string[]> | undefined = undefined;
if (this.clientOpts?.lazyLoadMembers) {
params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
if (!res.event) {
throw new Error("'event' not in '/context' result - homeserver too old?");
}
const res = await this.getEventContext(timelineSet.room.roomId, eventId);
// by the time the request completes, the event might have ended up in the timeline.
if (timelineSet.getTimelineForEvent(eventId)) {
@@ -4396,7 +4619,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const event = mapper(res.event);
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
this.logger.warn("Tried loading a regular timeline at the position of a thread event");
return undefined;
return null;
}
const events = [
// Order events from most recent to oldest (reverse-chronological).
@@ -4414,7 +4637,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} else {
timeline = timelineSet.addTimeline();
timeline.initialiseState(res.state.map(mapper));
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end ?? null;
}
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
@@ -4447,20 +4670,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("could not get thread timeline: not a thread timeline");
}
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
$eventId: eventId,
});
const res = await this.getEventContext(timelineSet.room.roomId, eventId);
const params: Record<string, string | string[]> = {
limit: "0",
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
const mapper = this.getEventMapper();
const event = mapper(res.event);
@@ -4540,7 +4751,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const eventsNewer: IEvent[] = [];
let nextBatch: Optional<string> = res.end;
let nextBatch = res.end;
while (nextBatch) {
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
@@ -4549,7 +4760,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
null,
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
);
nextBatch = resNewer.next_batch ?? null;
nextBatch = resNewer.next_batch;
eventsNewer.push(...resNewer.chunk);
}
const events = [
@@ -4593,7 +4804,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves:
* {@link EventTimeline} timeline with the latest events in the room
*/
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> {
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<EventTimeline | null> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error(
@@ -5952,6 +6163,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return rooms;
}
/**
* Returns a set of configured RTC transports supported by the homeserver.
* Requires homeserver support for MSC4143.
* @throws A M_NOT_FOUND error if not supported by the homeserver.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public async _unstable_getRTCTransports(): Promise<Transport[]> {
// There is no /versions endpoint to check for support, so we just have to attempt a request.
return (
await this.http.authedRequest<{
rtc_transports: Transport[];
}>(Method.Get, "/rtc/transports", undefined, undefined, {
prefix: `${ClientPrefix.Unstable}/org.matrix.msc4143`,
})
).rtc_transports;
}
/**
* Get the API versions supported by the server, along with any
* unstable APIs it supports
@@ -6805,7 +7033,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return;
}
// Check if the event is excluded under MSC3414
// Check if the event is excluded under MSC4362
if (
[
"m.room.create",
@@ -6897,9 +7125,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Get the public rooms list from the server. Supports pagination
* @param params - Options for this request
* @returns Promise which resolves: IPublicRoomsResponse
* @returns Rejects: with an error response.
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
*/
public publicRooms({
server,
+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;
}
/**
+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 {};
}
+8 -1
View File
@@ -59,7 +59,14 @@ export class ClientStoppedError extends Error {
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";
+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,
+149 -22
View File
@@ -19,9 +19,11 @@ import { deepCompare } from "../utils.ts";
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
import type { RTCCallIntent, Transport } from "./types.ts";
import { type IContent, type MatrixEvent } from "../models/event.ts";
import { MatrixEvent, type IContent } from "../models/event.ts";
import { type RelationType } from "../@types/event.ts";
import { logger } from "../logger.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.
@@ -31,7 +33,16 @@ import { logger } from "../logger.ts";
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
type CallScope = "m.room" | "m.user";
type Member = { user_id: string; device_id: string; id: string };
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;
@@ -199,6 +210,15 @@ export type SessionMembershipData = {
* 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: IContent, errors: string[]): data is SessionMembershipData => {
@@ -243,38 +263,117 @@ export class CallMembership {
return deepCompare(a?.membershipData, b?.membershipData);
}
private membershipData: MembershipData;
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 };
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(
/** The Matrix event that this membership is based on */
private readonly matrixEvent: MatrixEvent,
data: IContent,
/** 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 eventId = matrixEvent.getId();
const sender = matrixEvent.getSender();
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 {
// 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");
this.matrixEventData = { eventId, sender, ts };
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
}
/**
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
* `${this.userId}:${this.deviceId}` for state events (kind = session)
*/
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(data, sessionErrors)) {
this.membershipData = { kind: "session", data };
} else if (checkRtcMembershipData(data, rtcErrors, sender)) {
this.membershipData = { kind: "rtc", data };
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(data).replaceAll('"', "'");
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
throw Error(`unknown CallMembership data.\n` + details + json);
}
this.matrixEventData = { eventId, sender };
}
/** @deprecated use userId instead */
@@ -330,7 +429,7 @@ export class CallMembership {
if (typeof intent === "string") {
return intent;
}
logger.warn("RTC membership has invalid m.call.intent");
this.logger?.warn("RTC membership has invalid m.call.intent");
return undefined;
}
case "session":
@@ -378,8 +477,28 @@ export class CallMembership {
return data.scope;
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
/**
* 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 events where we needed to update them
// synapse ignores sending state events if they have the same content.
@@ -389,7 +508,12 @@ export class CallMembership {
return data.member.id;
case "session":
default:
return (this.createdTs() ?? "").toString();
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}`
);
}
}
@@ -398,10 +522,10 @@ export class CallMembership {
switch (kind) {
case "rtc":
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEvent.getTs();
return this.matrixEventData.ts;
case "session":
default:
return data.created_ts ?? this.matrixEvent.getTs();
return data.created_ts ?? this.matrixEventData.ts;
}
}
@@ -510,4 +634,7 @@ export class CallMembership {
return data.foci_preferred;
}
}
public get kind(): MembershipData["kind"] {
return this.membershipData.kind;
}
}
+98 -51
View File
@@ -5,8 +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 { 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.
@@ -38,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:
@@ -67,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
@@ -79,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;
}
@@ -126,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();
@@ -147,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
@@ -202,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;
}
@@ -240,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);
}
/**
@@ -260,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!");
@@ -294,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) {
@@ -307,16 +342,18 @@ export class EncryptionManager implements IEncryptionManager {
}
};
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()}`),
);
}
@@ -344,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;
}
@@ -376,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).
@@ -388,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),
);
}
}
@@ -419,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,
+16 -2
View File
@@ -26,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;
};
/**
@@ -40,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.
@@ -76,6 +84,12 @@ 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.
+155 -119
View File
@@ -25,7 +25,7 @@ import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import type {
Statistics,
@@ -73,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]: (
@@ -178,7 +179,8 @@ 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;
@@ -234,7 +236,7 @@ export interface EncryptionConfig {
}
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
interface SessionMembershipsForRoomOpts {
interface SessionMembershipsForSlotOpts {
/**
* Listen for incoming sticky member events. If disabled, this session will
* ignore any incoming sticky events.
@@ -268,6 +270,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
private expiryTimeout?: ReturnType<typeof setTimeout>;
public memberships: CallMembership[] = [];
/**
* The statistics for this session.
*/
@@ -284,10 +288,12 @@ 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.
@@ -310,28 +316,17 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
* @returns an empty array.
*/
public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
): CallMembership[] {
return MatrixRTCSession.sessionMembershipsForSlot(room, {
id: "",
application: "m.call",
});
}
/**
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
*/
public static sessionMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
sessionDescription: SlotDescription,
): CallMembership[] {
return this.sessionMembershipsForSlot(room, sessionDescription);
rootLogger.error(
`[MatrixRTCSession ${room.roomId}] callMembershipsForRoom is deprecated. Use sessionMembershipsForSlot instead.`,
);
return [];
}
/**
@@ -340,16 +335,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
*
* By default, this will return *both* sticky and member state events.
*/
public static sessionMembershipsForSlot(
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 }: SessionMembershipsForRoomOpts = {
{ listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForSlotOpts = {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
},
): CallMembership[] {
): Promise<CallMembership[]> {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
let callMemberEvents = [] as MatrixEvent[];
if (listenForStickyEvents) {
@@ -361,8 +356,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
if (listenForMemberStateEvents) {
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);
logger.warn("Couldn't get state for room " + room.roomId + "using empty membership array");
return [];
}
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
callMemberEvents = callMemberEvents.concat(
@@ -385,45 +380,50 @@ export class MatrixRTCSession extends TypedEventEmitter<
// 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 && "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) {
if (!("application" in membershipData)) {
// This is a left membership event, ignore it here to not log warnings.
if (!("application" in membershipContent)) {
// This is a left membership event, ignore it here to not log warnings.
continue;
}
try {
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
const membership = new CallMembership(
memberEvent,
membershipData,
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
rootLogger,
);
if (!deepCompare(membership.slotDescription, slotDescription)) {
logger.info(
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
);
continue;
}
try {
const membership = new CallMembership(memberEvent, membershipData);
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);
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);
}
}
@@ -431,43 +431,13 @@ 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.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
*/
public static roomSessionForRoom(
client: MatrixClient,
room: Room,
opts?: SessionMembershipsForRoomOpts,
): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(
room,
{ id: "", application: "m.call" },
opts,
);
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
}
/**
* @deprecated Use `MatrixRTCSession.sessionForSlot` instead.
*/
public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
return this.sessionForSlot(client, room, slotDescription);
}
/**
* Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active sessions
@@ -477,10 +447,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
client: MatrixClient,
room: Room,
slotDescription: SlotDescription,
opts?: SessionMembershipsForRoomOpts,
opts?: SessionMembershipsForSlotOpts,
): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription, opts);
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
return new MatrixRTCSession(client, room, slotDescription, opts);
}
/**
@@ -503,7 +472,9 @@ 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<
@@ -514,6 +485,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
@@ -526,21 +500,19 @@ export class MatrixRTCSession extends TypedEventEmitter<
Room,
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
>,
public memberships: CallMembership[],
/**
* 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 readonly slotDescription: SlotDescription,
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
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();
}
/*
@@ -560,8 +532,8 @@ 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);
}
@@ -577,6 +549,8 @@ 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 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
@@ -584,7 +558,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
* transport selection will be used instead.
* @param joinConfig - Additional configuration for the joined session.
*/
public joinRoomSession(
public joinRTCSession(
ownMembershipIdentity: CallMembershipIdentityParts,
fociPreferred: Transport[],
multiSfuFocus?: Transport,
joinConfig?: JoinSessionConfig,
@@ -600,6 +575,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.roomSubset,
this.client,
this.slotDescription,
ownMembershipIdentity.memberId,
this.logger,
)
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
@@ -613,39 +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];
const transport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
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,
);
},
);
@@ -666,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.
@@ -743,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,
);
});
});
}
@@ -770,7 +780,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
if (soonestExpiry != undefined) {
this.expiryTimeout = setTimeout(this.onRTCSessionMemberUpdate, soonestExpiry);
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
}
}
@@ -836,7 +846,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* Call this when the Matrix room members have changed.
*/
private readonly onRoomMemberUpdate = (): void => {
this.recalculateSessionMembers();
this.ensureRecalculateSessionMembers();
};
/**
@@ -852,17 +862,38 @@ export class MatrixRTCSession extends TypedEventEmitter<
(e) => e.getType() === EventType.RTCMembership,
)
) {
this.recalculateSessionMembers();
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.
*
@@ -870,9 +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.sessionMembershipsForSlot(this.room, this.slotDescription);
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
this.room,
this.slotDescription,
this.calculateMembershipsOpts,
);
const changed =
oldMemberships.length != this.memberships.length ||
+11 -7
View File
@@ -66,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
// returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription);
const session = MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription);
if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session);
}
@@ -104,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.slotDescription),
MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription),
);
}
@@ -112,7 +112,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
}
private onRoom = (room: Room): void => {
this.refreshRoom(room);
void this.refreshRoom(room);
};
private readonly onEvent = (event: MatrixEvent): void => {
@@ -123,7 +123,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
const room = this.client.getRoom(event.getRoomId());
if (!room) return;
this.refreshRoom(room);
void this.refreshRoom(room);
};
private readonly onRoomState = (event: MatrixEvent): void => {
@@ -136,10 +136,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
return;
}
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);
@@ -149,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;
+50 -32
View File
@@ -16,11 +16,7 @@ limitations under the License.
import { AbortError } from "p-retry";
import { EventType, RelationType } from "../@types/event.ts";
import {
type ISendEventResponse,
type SendDelayedEventResponse,
UpdateDelayedEventAction,
} from "../@types/requests.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";
@@ -169,7 +165,14 @@ function createReplaceActionUpdate(type: MembershipActionType, offset?: number):
type MembershipManagerClient = Pick<
MatrixClient,
"getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent"
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
>;
/**
@@ -273,13 +276,8 @@ export class MembershipManager
if (!this.isActivated()) {
return Promise.resolve();
}
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId || !deviceId) {
this.logger.error("MembershipManager.onRTCSessionMemberUpdate called without user or device id");
return Promise.resolve();
}
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
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
@@ -335,9 +333,10 @@ 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.memberId = this.makeMembershipStateKey(userId, deviceId);
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
this.state = MembershipManager.defaultState;
this.callIntent = joinConfig?.callIntent;
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
@@ -383,7 +382,8 @@ export class MembershipManager
}
// Membership Event static parameters:
protected deviceId: string;
protected memberId: 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[];
@@ -484,7 +484,7 @@ export class MembershipManager
{ delay: this.delayedLeaveEventDelayMs },
EventType.GroupCallMemberPrefix,
{},
this.memberId,
this.stateKey,
);
// HANDLERS (used in the membershipLoopHandler)
@@ -499,7 +499,7 @@ export class MembershipManager
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
@@ -544,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)) {
@@ -584,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
@@ -606,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
@@ -630,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
@@ -647,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);
@@ -658,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)
@@ -680,7 +684,7 @@ export class MembershipManager
this.room.roomId,
EventType.GroupCallMemberPrefix,
myMembership as EmptyObject | SessionMembershipData,
this.memberId,
this.stateKey,
);
};
@@ -763,6 +767,10 @@ 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.slotDescription.application}${this.slotDescription.id}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
@@ -793,6 +801,10 @@ export class MembershipManager
"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,
"m.call.intent": this.callIntent,
...focusObjects,
@@ -1022,6 +1034,9 @@ export class MembershipManager
public get probablyLeft(): boolean {
return this.state.probablyLeft;
}
public get delayId(): string | undefined {
return this.state.delayId;
}
}
/**
@@ -1035,6 +1050,9 @@ export class StickyEventMembershipManager extends MembershipManager {
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);
@@ -1083,7 +1101,7 @@ export class StickyEventMembershipManager extends MembershipManager {
},
slot_id: slotDescriptionToId(this.slotDescription),
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
member: { device_id: this.deviceId, user_id: this.userId, id: this.memberId },
versions: [],
...relationObject,
};
+145 -45
View File
@@ -14,21 +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,
import {
type EncryptionKeyMapKey,
type InboundEncryptionSession,
type OutboundEncryptionSession,
type ParticipantDeviceInfo,
type Statistics,
} from "./types.ts";
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
import { OutdatedKeyFilter } from "./utils.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
@@ -46,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;
@@ -96,43 +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);
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
void this.getOwnRtcBackendIdentity(); // precompute own identity
this.logger?.info(`Joining room`);
this.transport.start();
}
@@ -167,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.
*/
@@ -220,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
@@ -253,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) =>
@@ -322,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) {
@@ -354,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;
+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,
+6 -7
View File
@@ -16,8 +16,9 @@ limitations under the License.
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;
@@ -159,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",
}
+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}`;
}
+8 -4
View File
@@ -57,14 +57,18 @@ export interface IRoomTimelineData {
liveEvent?: boolean;
}
export interface IAddEventToTimelineOptions
extends Pick<IAddEventOptions, "toStartOfTimeline" | "roomState" | "timelineWasEmpty" | "addToState"> {
export interface IAddEventToTimelineOptions extends Pick<
IAddEventOptions,
"toStartOfTimeline" | "roomState" | "timelineWasEmpty" | "addToState"
> {
/** Whether the sync response came from cache */
fromCache?: boolean;
}
export interface IAddLiveEventOptions
extends Pick<IAddEventToTimelineOptions, "fromCache" | "roomState" | "timelineWasEmpty" | "addToState"> {
export interface IAddLiveEventOptions extends Pick<
IAddEventToTimelineOptions,
"fromCache" | "roomState" | "timelineWasEmpty" | "addToState"
> {
/** Applies to events in the timeline only. If this is 'replace' then if a
* duplicate is encountered, the event passed to this function will replace
* the existing event in the timeline. If this is not specified, or is
+22 -43
View File
@@ -19,9 +19,8 @@ limitations under the License.
* the public classes.
*/
import { type ExtensibleEvent, ExtensibleEvents, type Optional } from "matrix-events-sdk";
import { type ExtensibleEvent, ExtensibleEvents } from "matrix-events-sdk";
import type { IEventDecryptionResult } from "../@types/crypto.ts";
import { logger } from "../logger.ts";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
@@ -40,7 +39,7 @@ import { TypedReEmitter } from "../ReEmitter.ts";
import { type MatrixError } from "../http-api/index.ts";
import { TypedEventEmitter } from "./typed-event-emitter.ts";
import { type EventStatus } from "./event-status.ts";
import { type CryptoBackend, DecryptionError } from "../common-crypto/CryptoBackend.ts";
import { type CryptoBackend, DecryptionError, type EventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
import { type IAnnotatedPushRule } from "../@types/PushRules.ts";
import { type Room } from "./room.ts";
import { EventTimeline } from "./event-timeline.ts";
@@ -186,8 +185,7 @@ export interface IDecryptOptions {
isRetry?: boolean;
/**
* Whether the message should be re-decrypted if it was previously successfully decrypted with an untrusted key.
* Defaults to `false`.
* @deprecated does nothing
*/
forceRedecryptIfUntrusted?: boolean;
}
@@ -270,7 +268,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// addition to a falsy cached event value. We check the flag later on in
// a public getter to decide if the cache is valid.
private _hasCachedExtEv = false;
private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
private _cachedExtEv?: ExtensibleEvent = undefined;
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
private _decryptionFailureReason: DecryptionFailureCode | null = null;
@@ -285,16 +283,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*/
private claimedEd25519Key: string | null = null;
/* curve25519 keys of devices involved in telling us about the
* senderCurve25519Key and claimedEd25519Key.
* See getForwardingCurve25519KeyChain().
*/
private forwardingCurve25519KeyChain: string[] = [];
/* where the decryption key is untrusted
*/
private untrusted: boolean | null = null;
/* if we have a process decrypting this event, a Promise which resolves
* when it is finished. Normally null.
*/
@@ -481,9 +469,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*
* @deprecated Use stable functions where possible.
*/
public get unstableExtensibleEvent(): Optional<ExtensibleEvent> {
public get unstableExtensibleEvent(): ExtensibleEvent | undefined {
if (!this._hasCachedExtEv) {
this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent());
this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent()) ?? undefined;
}
return this._cachedExtEv;
}
@@ -753,7 +741,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/**
* Get the event state_key if it has one. If necessary, this will perform
* string-unpacking on the state key, as per MSC3414. This will return
* string-unpacking on the state key, as per MSC4362. This will return
* <code>undefined</code> for message events.
* @returns The event's `state_key`.
*/
@@ -766,7 +754,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/**
* Get the raw event state_key if it has one. This may be string-packed as per
* MSC3414 if the state event is encrypted. This will return <code>undefined
* MSC4362 if the state event is encrypted. This will return <code>undefined
* </code> for message events.
* @returns The event's `state_key`.
*/
@@ -789,7 +777,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @returns The user's room membership, or `undefined` if the server does
* not report it.
*/
public getMembershipAtEvent(): Optional<Membership | string> {
public getMembershipAtEvent(): Membership | string | undefined {
const unsigned = this.getUnsigned();
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
}
@@ -894,8 +882,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
const alreadyDecrypted = this.clearEvent && !this.isDecryptionFailure();
const forceRedecrypt = options.forceRedecryptIfUntrusted && this.isKeySourceUntrusted();
if (alreadyDecrypted && !forceRedecrypt) {
if (alreadyDecrypted) {
// we may want to just ignore this? let's start with rejecting it.
throw new Error("Attempt to decrypt event which has already been decrypted");
}
@@ -1025,12 +1012,10 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*
* @param decryptionResult - the decryption result, including the plaintext and some key info
*/
private setClearData(decryptionResult: IEventDecryptionResult): void {
private setClearData(decryptionResult: EventDecryptionResult): void {
this.clearEvent = decryptionResult.clearEvent;
this.senderCurve25519Key = decryptionResult.senderCurve25519Key ?? null;
this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
this.untrusted = decryptionResult.untrusted || false;
this.invalidateExtensibleEvent();
}
@@ -1049,8 +1034,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
};
this.senderCurve25519Key = null;
this.claimedEd25519Key = null;
this.forwardingCurve25519KeyChain = [];
this.untrusted = false;
this.invalidateExtensibleEvent();
}
@@ -1120,29 +1103,25 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* Get the curve25519 keys of the devices which were involved in telling us
* about the claimedEd25519Key and sender curve25519 key.
* Returns an empty array.
*
* Normally this will be empty, but in the case of a forwarded megolm
* session, the sender keys are sent to us by another device (the forwarding
* device), which we need to trust to do this. In that case, the result will
* be a list consisting of one entry.
* Previously, this returned the chain of Curve25519 keys through which
* this session was forwarded, via `m.forwarded_room_key` events.
* However, that is not cryptographically reliable, and clients should not
* be using it.
*
* If the device that sent us the key (A) got it from another device which
* it wasn't prepared to vouch for (B), the result will be [A, B]. And so on.
*
* @returns base64-encoded curve25519 keys, from oldest to newest.
* @see https://github.com/matrix-org/matrix-spec/issues/1089
* @deprecated
*/
public getForwardingCurve25519KeyChain(): string[] {
return this.forwardingCurve25519KeyChain;
return [];
}
/**
* Whether the decryption key was obtained from an untrusted source. If so,
* we cannot verify the authenticity of the message.
* @deprecated always returns false
*/
public isKeySourceUntrusted(): boolean | undefined {
return !!this.untrusted;
public isKeySourceUntrusted(): false {
return false;
}
public getUnsigned(): IUnsigned {
+9 -2
View File
@@ -94,16 +94,23 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
* True if the room member is currently typing.
*/
public typing = false;
/**
* The human-readable name for this room member. This will be
* The human-readable name for this room member. Similar to {@link rawDisplayName}, but
* disambiguated with a suffix of " (\@user_id:matrix.org)" if another member shares the
* same displayname.
*/
public name: string;
/**
* The ambiguous displayname of this room member.
* The ambiguous displayname of this room member, with some preprocessing:
*
* * Direction override characters (RTO and LRO) are removed.
* * If the displayname is empty, or contains only blank, non-printing, or diacritcic characters, it is
* replaced with the user ID.
*/
public rawDisplayName: string;
/**
* The power level for this room member.
*/
+15 -20
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START, type Optional } from "matrix-events-sdk";
import { M_POLL_START } from "matrix-events-sdk";
import {
DuplicateStrategy,
@@ -1196,7 +1196,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// Get the main TimelineSet
const timelineSet = this.getUnfilteredTimelineSet();
let newTimeline: Optional<EventTimeline>;
let newTimeline: EventTimeline | null = null;
// If there isn't any event in the timeline, let's go fetch the latest
// event and construct a timeline from it.
//
@@ -1783,12 +1783,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
allowDefault = true,
useAuthentication: boolean = false,
): string | null {
const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, "");
if (!roomAvatarEvent && !allowDefault) {
const mainUrl = this.getMxcAvatarUrl();
if (!mainUrl && !allowDefault) {
return null;
}
const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
if (mainUrl) {
return getHttpUriForMxc(
baseUrl,
@@ -1810,7 +1809,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @returns the mxc avatar url or falsy
*/
public getMxcAvatarUrl(): string | null {
return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null;
const url = this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent().url;
return url && typeof url === "string" ? url : null;
}
/**
@@ -1820,11 +1820,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @returns The room's canonical alias, or null if there is none
*/
public getCanonicalAlias(): string | null {
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
if (canonicalAlias) {
return canonicalAlias.getContent().alias || null;
}
return null;
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "")?.getContent().alias;
return canonicalAlias && typeof canonicalAlias === "string" ? canonicalAlias : null;
}
/**
@@ -1832,9 +1829,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @returns The room's alternative aliases, or an empty array
*/
public getAltAliases(): string[] {
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
if (canonicalAlias) {
return canonicalAlias.getContent().alt_aliases || [];
const altAliases = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "")?.getContent().alt_aliases;
if (Array.isArray(altAliases)) {
return altAliases.filter((alias) => typeof alias === "string");
}
return [];
}
@@ -2493,7 +2490,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
private updateThreadRootEvent = (
timelineSet: Optional<EventTimelineSet>,
timelineSet: EventTimelineSet | undefined,
thread: Thread,
toStartOfTimeline: boolean,
recreateEvent: boolean,
@@ -3640,13 +3637,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string {
if (!ignoreRoomNameEvent) {
// check for an alias, if any. for now, assume first alias is the
// official one.
const mRoomName = this.currentState.getStateEvents(EventType.RoomName, "");
if (mRoomName?.getContent().name) {
const name = this.currentState.getStateEvents(EventType.RoomName, "")?.getContent().name;
if (name && typeof name === "string") {
return this.roomNameGenerator({
type: RoomNameType.Actual,
name: mRoomName.getContent().name,
name,
});
}
}
+4 -6
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 { type MatrixClient, PendingEventOrdering } from "../client.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { RelationType } from "../@types/event.ts";
@@ -476,7 +474,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
}
}
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
public async processEvent(event: MatrixEvent | null | undefined): Promise<void> {
if (event) {
this.setEventMetadata(event);
await this.fetchEditsWhereNeeded(event);
@@ -686,14 +684,14 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
}
}
public setEventMetadata(event: Optional<MatrixEvent>): void {
public setEventMetadata(event: MatrixEvent | null | undefined): void {
if (event) {
EventTimeline.setEventMetadata(event, this.roomState, false);
event.setThread(this);
}
}
public clearEventMetadata(event: Optional<MatrixEvent>): void {
public clearEventMetadata(event: MatrixEvent | null | undefined): void {
if (event) {
event.setThread(undefined);
delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
@@ -739,7 +737,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* A getter for the last event of the thread.
* This might be a synthesized event, if so, it will not emit any events to listeners.
*/
public get replyToEvent(): Optional<MatrixEvent> {
public get replyToEvent(): MatrixEvent | null {
return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply();
}
+35 -10
View File
@@ -30,12 +30,15 @@ import { logger } from "../logger.ts";
*/
export class OidcTokenRefresher {
/**
* Promise which will complete once the OidcClient has been initialised
* and is ready to start refreshing tokens.
*
* Will reject if the client initialisation fails.
* This is now just a resolved promise and will be removed in a future version.
* Initialisation is done lazily at token refresh time.
* @deprecated Consumers no longer need to wait for this promise.
*/
public readonly oidcClientReady!: Promise<void>;
// If there is a initialisation attempt in progress, we keep track of it here.
private initPromise?: Promise<void>;
private oidcClient!: OidcClient;
private inflightRefreshRequest?: Promise<AccessTokens>;
@@ -43,26 +46,46 @@ export class OidcTokenRefresher {
/**
* The OIDC issuer as returned by the /auth_issuer API
*/
issuer: string,
private issuer: string,
/**
* id of this client as registered with the OP
*/
clientId: string,
private clientId: string,
/**
* redirectUri as registered with OP
*/
redirectUri: string,
private redirectUri: string,
/**
* Device ID of current session
*/
deviceId: string,
protected deviceId: string,
/**
* idTokenClaims as returned from authorization grant
* used to validate tokens
*/
private readonly idTokenClaims: IdTokenClaims,
) {
this.oidcClientReady = this.initialiseOidcClient(issuer, clientId, deviceId, redirectUri);
this.oidcClientReady = Promise.resolve();
}
/**
* Ensures that the client is initialised.
* @returns Promise that resolves when initialisation is complete
* @throws if initialisation fails
*/
private async ensureInit(): Promise<void> {
if (!this.oidcClient) {
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.initialiseOidcClient(this.issuer, this.clientId, this.deviceId, this.redirectUri);
try {
await this.initPromise;
} finally {
this.initPromise = undefined;
}
}
}
private async initialiseOidcClient(
@@ -98,6 +121,8 @@ export class OidcTokenRefresher {
* @throws when token refresh fails
*/
public async doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
await this.ensureInit();
if (!this.inflightRefreshRequest) {
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
}
@@ -123,7 +148,7 @@ export class OidcTokenRefresher {
* @param tokens.accessToken - new access token
* @param tokens.refreshToken - OPTIONAL new refresh token
*/
public async persistTokens(tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
protected async persistTokens(tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
// NOOP
}
@@ -139,5 +139,29 @@ export class OutgoingRequestsManager {
this.logger.error(`Failed to process outgoing request ${request.type}: ${e}`);
}
}
// If we handled any requests this time, more may have been queued as
// part of that handling, so do an extra check to make sure we handle
// them immediately. See
// If we handled any requests this time, more may have been queued as
// part of that handling.
//
// For example, we may have processed a `/keys/claim` request, which
// meant the rust side could establish an Olm session and is now ready to
// send out an `m.secret.send` message.
// (See https://github.com/element-hq/element-web/issues/30988.)
//
// So, if we have processed any requests, flag that we need make another
// pass around the outgoing-requests loop, to make sure we handle any
// pending requests immediately.
if (outgoingRequests.length > 0) {
// We call doProcessOutgoingRequests but since we expect that we are
// already processing outgoing requests, this call will not kick off
// the processing loop, but just set `nextLoopDeferred` and return,
// which will mean we loop one more time.
this.doProcessOutgoingRequests().catch((e) => {
this.logger.warn("processOutgoingRequests: Error re-checking outgoing requests", e);
});
}
}
}
+72 -9
View File
@@ -17,7 +17,7 @@ limitations under the License.
import anotherjson from "another-json";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
import type { IMegolmSessionData } from "../@types/crypto.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts";
import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts";
@@ -28,6 +28,7 @@ import {
type BackupDecryptor,
type CryptoBackend,
DecryptionError,
type EventDecryptionResult,
type OnSyncCompletedData,
} from "../common-crypto/CryptoBackend.ts";
import { type Logger, LogSpan } from "../logger.ts";
@@ -129,6 +130,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** mapping of roomId → encryptor class */
private roomEncryptors: Record<string, RoomEncryptor> = {};
/** mapping of room ID -> inviter ID for rooms pending MSC4268 key bundles */
private readonly roomsPendingKeyBundles: Map<string, string> = new Map();
private eventDecryptor: EventDecryptor;
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
@@ -164,7 +168,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** Crypto callbacks provided by the application */
private readonly cryptoCallbacks: CryptoCallbacks,
/** Enable support for encrypted state events under MSC3414. */
/** Enable support for encrypted state events under MSC4362. */
private readonly enableEncryptedStateEvents: boolean = false,
) {
super();
@@ -282,7 +286,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
await encryptor.encryptEvent(event, this.globalBlacklistUnverifiedDevices, this.deviceIsolationMode);
}
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
public async decryptEvent(event: MatrixEvent): Promise<EventDecryptionResult> {
const roomId = event.getRoomId();
if (!roomId) {
// presumably, a to-device message. These are normally decrypted in preprocessToDeviceMessages
@@ -329,10 +333,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/**
* Implementation of {@link CryptoBackend.maybeAcceptKeyBundle}.
*/
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void> {
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean> {
// TODO: retry this if it gets interrupted or it fails. (https://github.com/matrix-org/matrix-rust-sdk/issues/5112)
// TODO: do this in the background.
// TODO: handle the bundle message arriving after the invite (https://github.com/element-hq/element-web/issues/30740)
const logger = new LogSpan(this.logger, `maybeAcceptKeyBundle(${roomId}, ${inviter})`);
@@ -352,7 +355,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
);
if (!bundleData) {
logger.info("No key bundle found for user");
return;
return false;
}
logger.info(`Fetching key bundle ${bundleData.url}`);
@@ -391,7 +394,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
logger.warn(`Error receiving encrypted bundle:`, err);
throw err;
}
return true;
}
/**
* Implementation of {@link CryptoBackend.markRoomAsPendingKeyBundle}.
*/
public markRoomAsPendingKeyBundle(roomId: string, inviter: string): void {
this.roomsPendingKeyBundles.set(roomId, inviter);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// CryptoApi implementation
@@ -1703,6 +1716,37 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
senderVerified: encryptionInfo.isSenderVerified(),
},
});
// If we have received a room key bundle message, and have previously marked the room
// IDs it references as pending key bundles, tell the Rust SDK to try and accept it,
// just in case it was received after invite.
//
// We don't actually need to validate the contents of the bundle message, or do
// anything with its contents at all. We simply want to inform the Rust SDK we have
// received a new room key bundle that we might be able to download.
if (
isRoomKeyBundleMessage(parsedMessage) &&
this.roomsPendingKeyBundles.has(parsedMessage.content.room_id)
) {
// No `await`-ing here, as this is called from inside the `/sync` loop.
this.maybeAcceptKeyBundle(
parsedMessage.content.room_id,
this.roomsPendingKeyBundles.get(parsedMessage.content.room_id)!,
).then(
(success) => {
if (success) {
this.roomsPendingKeyBundles.delete(parsedMessage.content.room_id);
}
},
(err) => {
this.logger.error(
`Error attempting to download key bundle for room ${parsedMessage.content.room_id}`,
);
this.logger.error(err);
},
);
}
break;
}
case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText: {
@@ -1774,7 +1818,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
return;
}
if (config["io.element.msc3414.encrypt_state_events"] && this.enableEncryptedStateEvents) {
if (config["io.element.msc4362.encrypt_state_events"] && this.enableEncryptedStateEvents) {
this.logger.info("crypto Enabling state event encryption...");
settings.encryptStateEvents = true;
}
@@ -2159,7 +2203,7 @@ class EventDecryptor {
public async attemptEventDecryption(
event: MatrixEvent,
isolationMode: DeviceIsolationMode,
): Promise<IEventDecryptionResult> {
): Promise<EventDecryptionResult> {
// add the event to the pending list *before* attempting to decrypt.
// then, if the key turns up while decryption is in progress (and
// decryption fails), we will schedule a retry.
@@ -2192,7 +2236,6 @@ class EventDecryptor {
clearEvent: JSON.parse(res.event),
claimedEd25519Key: res.senderClaimedEd25519Key,
senderCurve25519Key: res.senderCurve25519Key,
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
};
} catch (err) {
if (err instanceof RustSdkCryptoJs.MegolmDecryptionError) {
@@ -2468,5 +2511,25 @@ function rustEncryptionInfoToJsEncryptionInfo(
return { shieldColour, shieldReason };
}
interface RoomKeyBundleMessage {
type: "io.element.msc4268.room_key_bundle";
content: {
room_id: string;
};
}
/**
* Determines if the given payload is a RoomKeyBundleMessage.
*
* A RoomKeyBundleMessage is identified by having a specific message type
* ("io.element.msc4268.room_key_bundle") and a valid room_id in its content.
*
* @param message - The received to-device message to check.
* @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise.
*/
function isRoomKeyBundleMessage(message: IToDeviceEvent): message is IToDeviceEvent & RoomKeyBundleMessage {
return message.type === "io.element.msc4268.room_key_bundle" && typeof message.content.room_id === "string";
}
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
type RustCryptoEvents = Exclude<CryptoEvents, CryptoEvent.LegacyCryptoStoreMigrationProgress>;
+1 -3
View File
@@ -23,8 +23,6 @@ limitations under the License.
* for HTTP and WS at some point.
*/
import { type Optional } from "matrix-events-sdk";
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend.ts";
import { User } from "./models/user.ts";
import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
@@ -208,7 +206,7 @@ export class SyncApi {
private readonly opts: IStoredClientOpts;
private readonly syncOpts: SyncApiOptions;
private _peekRoom: Optional<Room> = null;
private _peekRoom: Room | null = null;
private currentSyncRequest?: Promise<ISyncResponse>;
private abortController?: AbortController;
private syncState: SyncState | null = null;
-2
View File
@@ -179,9 +179,7 @@ export async function decryptExistingEvent(
type: opts.plainType,
content: opts.plainContent,
},
forwardingCurve25519KeyChain: [],
senderCurve25519Key: "",
untrusted: false,
};
const mockCrypto = {
+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";
import { type Direction, EventTimeline } from "./models/event-timeline.ts";
import { logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
@@ -105,7 +103,7 @@ export class TimelineWindow {
public load(initialEventId?: string, initialWindowSize = 20): Promise<void> {
// given an EventTimeline, find the event we were looking for, and initialise our
// fields so that the event in question is in the middle of the window.
const initFields = (timeline: Optional<EventTimeline>): void => {
const initFields = (timeline: EventTimeline | null): void => {
if (!timeline) {
throw new Error("No timeline given to initFields");
}
+1 -2
View File
@@ -20,7 +20,6 @@ limitations under the License.
import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import { type Optional } from "matrix-events-sdk";
import { type IEvent, type MatrixEvent } from "./models/event.ts";
import { M_TIMESTAMP } from "./@types/location.ts";
@@ -115,7 +114,7 @@ export function decodeParams(query: string): Record<string, string | string[]> {
* variables with. E.g. `{ "$bar": "baz" }`.
* @returns The result of replacing all template variables e.g. '/foo/baz'.
*/
export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string {
export function encodeUri(pathTemplate: string, variables: Record<string, string | null | undefined>): string {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
+3 -3
View File
@@ -3002,9 +3002,9 @@ export function supportsMatrixCall(): boolean {
try {
const supported = Boolean(
window.RTCPeerConnection ??
window.RTCSessionDescription ??
window.RTCIceCandidate ??
navigator.mediaDevices,
window.RTCSessionDescription ??
window.RTCIceCandidate ??
navigator.mediaDevices,
);
if (!supported) {
/* istanbul ignore if */ // Adds a lot of noise to test runs, so disable logging there.
+40 -23
View File
@@ -264,8 +264,19 @@ export class MediaHandler extends TypedEventEmitter<
}
if (!canReuseStream) {
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
stream = await navigator.mediaDevices.getUserMedia(constraints);
let constraints: MediaStreamConstraints;
try {
// Not specifying exact for deviceId means switching devices does not always work,
// try with exact and fallback to ideal if it fails
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, true);
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (e) {
logger.warn(
`MediaHandler getUserMediaStreamInternal() error (e=${e}), retrying without exact deviceId`,
);
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, false);
stream = await navigator.mediaDevices.getUserMedia(constraints);
}
logger.log(
`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
stream.id
@@ -435,30 +446,36 @@ export class MediaHandler extends TypedEventEmitter<
this.emit(MediaHandlerEvent.LocalStreamsChanged);
}
private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints {
const isWebkit = !!navigator.webkitGetUserMedia;
const deviceIdKey = exactDeviceId ? "exact" : "ideal";
const audioConstraints: MediaTrackConstraints = {};
if (this.audioInput) {
audioConstraints.deviceId = { [deviceIdKey]: this.audioInput };
}
if (this.audioSettings) {
audioConstraints.autoGainControl = { ideal: this.audioSettings.autoGainControl };
audioConstraints.echoCancellation = { ideal: this.audioSettings.echoCancellation };
audioConstraints.noiseSuppression = { ideal: this.audioSettings.noiseSuppression };
}
const videoConstraints: MediaTrackConstraints = {
/* We want 640x360. Chrome will give it only if we ask exactly,
FF refuses entirely if we ask exactly, so have to ask for ideal
instead
XXX: Is this still true?
*/
width: isWebkit ? { exact: 640 } : { ideal: 640 },
height: isWebkit ? { exact: 360 } : { ideal: 360 },
};
if (this.videoInput) {
videoConstraints.deviceId = { [deviceIdKey]: this.videoInput };
}
return {
audio: audio
? {
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
}
: false,
video: video
? {
deviceId: this.videoInput ? { ideal: this.videoInput } : undefined,
/* We want 640x360. Chrome will give it only if we ask exactly,
FF refuses entirely if we ask exactly, so have to ask for ideal
instead
XXX: Is this still true?
*/
width: isWebkit ? { exact: 640 } : { ideal: 640 },
height: isWebkit ? { exact: 360 } : { ideal: 360 },
}
: false,
audio: audio ? audioConstraints : false,
video: video ? videoConstraints : false,
};
}
+1548 -1516
View File
File diff suppressed because it is too large Load Diff