Compare commits

..

154 Commits

Author SHA1 Message Date
Michael Telatynski 14b2ee2da4 Remove dependency on uuid (#5297)
Notify Downstream Projects / notify-downstream (element-web-notify, element-hq/element-web) (push) Has been skipped
Static Analysis / Typescript Syntax Check (push) Failing after 1m36s
Static Analysis / ESLint (push) Failing after 31s
Static Analysis / Node.js example (push) Failing after 42s
Static Analysis / Workflow Lint (push) Failing after 9m59s
Static Analysis / JSDoc Checker (push) Failing after 50s
Static Analysis / Analyse Dead Code (push) Failing after 36s
Static Analysis / Downstream tsc element-web (push) Has been skipped
Tests / Vitest [integ] (Node 22) (push) Failing after 4s
Tests / Vitest [unit] (Node 22) (push) Failing after 39s
Tests / Vitest [integ] (Node lts/*) (push) Failing after 39s
Tests / Vitest [unit] (Node lts/*) (push) Failing after 35s
Tests / Downstream test element-web (push) Has been skipped
Tests / Run Complement Crypto tests (push) Has been skipped
Static Analysis / Static Analysis (push) Successful in 1s
Tests / Tests (push) Failing after 1s
Tests / Downstream Complement Crypto tests (push) Successful in 1s
Tests / Downstream tests (push) Successful in 4s
2026-04-23 09:53:18 +00:00
renovate[bot] bb083222d9 Update dependency vite to v8 (#5295)
* Update dependency vite to v8

* Update lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-22 16:16:29 +00:00
Andy Balaam fa424c44b4 Support stable identifiers for MSC4268 (#5290)
* Support stable identifier m.room_key_bundle

* Support stable identifier m.shared_history

* Test that checks isRoomKeyBundleMessage works for stable and unstable identifiers

* Replace similar tests with use of it.each
2026-04-22 09:10:39 +00:00
renovate[bot] fef093747e Update dependency @typescript-eslint/eslint-plugin to v8.59.0 (#5292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:38:05 +00:00
renovate[bot] 4b33892d48 Update npm non-major dependencies (#5293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:58:22 +00:00
renovate[bot] d7d771fadb Update vite to v4.1.5 (#5291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 12:37:38 +00:00
Hubert Chathi 4ee3e591bf Handle secret pushing for key backups (#5189)
* push backup key to other verified devices when we reset backup

* handle receiving pushed backup keys

- make sure that backup gets enabled after we receive a pushed key that
  matches the current, valid backup

* apply requested changes from review
2026-04-20 21:48:51 +00:00
renovate[bot] 668183d722 Update vite to v4.1.4 (#5289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 11:25:37 +00:00
renovate[bot] 854dae0dc0 Update typescript (#5288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 10:01:43 +00:00
renovate[bot] 9d1aca2232 Update eslint-plugins (#5286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 09:11:28 +00:00
renovate[bot] 81569f3461 Update actions/setup-node digest to 48b55a0 (#5285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 08:50:10 +00:00
renovate[bot] 50783aba76 Update npm non-major dependencies (#5287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 08:17:20 +00:00
Michael Telatynski fd01b17236 Tidy knip config (#5284) 2026-04-17 13:00:21 +00:00
Will Hunt 25e92009b7 Throw more helpful errors from indexedb-local-backend (#5282)
* Throw more helpful errors from indexedb-local-backend

* Add a test

* Do not tempt fate
2026-04-17 08:33:56 +00:00
Michael Telatynski eb7acfb810 Add support for m.recent_emoji account data event (#5280) 2026-04-16 21:59:30 +00:00
Andy Balaam ca5655bced Rotate the room key when anyone leaves a room for any reason (#5279) 2026-04-16 12:09:42 +00:00
Michael Telatynski ef9b13e2a6 Fix coverage reports clobbering each other (#5267)
merge-multiple would silently drop files with clashing names - it ultimately isn't necessary given the `find` command will happily find them in nested subdirs
2026-04-16 11:48:51 +00:00
fkwp 159cca0363 Adapt LiveKit Identity hash calculation to latest MSC4195 update (#5268)
* adapt hash calculation to latest MSC4195 update.

Stop using '|' delimiters in hashes; use JSON arrays + canonical JSON instead

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* Update the test for RTC backend identities and add tests for calculating the identity hash.

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* linting

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* add copyright header

---------

Signed-off-by: fkwp <github-fkwp@w4ve.de>
2026-04-16 09:48:11 +00:00
renovate[bot] 3879111850 Update typescript (#5274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 11:37:50 +00:00
Andy Balaam f9a5aa87e3 Support the stable prefix for MSC4287 (key backup preference) (#5258)
* Support the stable prefix for MSC4287 (key backup preference)

* Remove incorrect doc coment on disableKeyStorage
2026-04-15 11:07:48 +00:00
renovate[bot] ed58df040c Update eslint-plugins (#5276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 09:51:25 +00:00
Michael Telatynski 0a3448d4c9 Fix references to matrix-react-sdk in CI (#5269) 2026-04-15 09:25:54 +00:00
renovate[bot] 25c1c1ea26 Update actions/github-script action to v9 (#5277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 09:11:09 +00:00
renovate[bot] a5e67af31f Update actions/upload-pages-artifact action to v5 (#5278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:37:38 +00:00
renovate[bot] b91e80814a Update zizmorcore/zizmor-action action to v0.5.3 (#5275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:37:07 +00:00
renovate[bot] d096a72605 Update npm non-major dependencies (#5273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:36:56 +00:00
renovate[bot] fb547e7b4b Update actions/upload-artifact digest to 043fb46 (#5272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:36:46 +00:00
Michael Telatynski 815294ca5a Allow oidc jwks_uri to be omitted (#5271)
* Allow oidc jwks_uri to be null

As signing keys are seemingly not (yet?) in the spec

* Add test coverage
2026-04-15 08:15:29 +00:00
Michael Telatynski 6d270b4685 Handle response_mode=fragment in completeAuthorizationCodeGrant (#5266)
* Handle response_mode=fragment in completeAuthorizationCodeGrant

* Add test

* Fix docs
2026-04-14 14:59:10 +00:00
Michael Telatynski 8bc3d96f6b Allow generating OIDC URIs with response_mode=fragment (#5265) 2026-04-13 22:53:05 +00:00
Richard van der Hoff b6ea6e105e Log clarifications for handleBackupSecretReceived (#5233)
Some changes to make this a bit easier to understand.
2026-04-10 12:33:30 +00:00
Valere Fedronic cd4e053fa5 Suppress and Reduce noisy logs for rtc (#5260) 2026-04-09 14:24:36 +00:00
Andy Balaam 727473af62 Expand the comment on CryptoApi.getUserDeviceInfo saying we request info from the server (#5256)
* Expand the comment on CryptoApi.getUserDeviceInfo saying we request info from the server

* Update comment to reflect waiting for in-progress requests, not making new ones

* Update the comment for userHasCrossSigningKeys too
2026-04-09 14:19:22 +00:00
Michael Telatynski f17f013f1e Satisfy pnpm audit (#5262) 2026-04-09 11:52:19 +00:00
renovate[bot] 9f4ab0b840 Update matrix-org/sonarcloud-workflow-action digest to 13968a2 (#5263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 13:28:46 +00:00
Michael Telatynski 6371e4b252 Update pnpm command for version bumping 2026-04-08 08:23:52 +01:00
Michael Telatynski b69929e01a Allow release-make to bump multiple package.json versions (#5261) 2026-04-07 14:44:15 +00:00
RiotRobot 9dc12baaa9 Merge branch 'master' into develop 2026-04-07 13:17:46 +00:00
RiotRobot 159738597d v41.3.0 2026-04-07 13:17:11 +00:00
Andy Balaam d02205652f Re-enable Complement Crypto tests (#5257)
Since https://github.com/matrix-org/complement-crypto/pull/235 these
should be more reliable.

This reverts commit 4d59291538.
2026-04-01 12:38:11 +00:00
Richard van der Hoff 5e03add29a Expose UserVerificationStatus.known flag (#5255)
Indicate whether we have a record of this user's identity.
2026-04-01 09:58:50 +00:00
renovate[bot] eeafd7fcaa Update npm non-major dependencies (#5251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 16:36:53 +00:00
renovate[bot] 78a3c5372d Update vite (#5253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:58:28 +00:00
renovate[bot] c0c3bc2a8c Update dependency p-retry to v8 (#5254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:28:47 +00:00
renovate[bot] c3ce49cabf Update pnpm to v10.33.0 (#5252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:19:11 +00:00
renovate[bot] 5408168dfd Update dependency eslint-plugin-jsdoc to v62.8.1 (#5250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:17:51 +00:00
renovate[bot] 61452ddc11 Update pnpm/action-setup action to v5 (#5239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:11:15 +00:00
Michael Telatynski d5160a5380 Add permissions for contents in workflow 2026-03-31 14:36:05 +01:00
Michael Telatynski 7ff4960a27 Update workflow to use build-and-test.yaml from EW
Moved in https://github.com/element-hq/element-web/pull/32929
2026-03-31 14:34:06 +01:00
RiotRobot 00f63db80f v41.3.0-rc.0 2026-03-31 12:33:30 +00:00
dependabot[bot] 9bcb83a20a Bump smol-toml from 1.6.0 to 1.6.1 (#5249)
Bumps [smol-toml](https://github.com/squirrelchat/smol-toml) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 11:59:06 +00:00
dependabot[bot] dd8d8e5410 Bump brace-expansion from 1.1.12 to 1.1.13 (#5248)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 11:41:40 +00:00
dependabot[bot] 32b8ff8116 Bump minimatch from 3.1.2 to 3.1.5 (#5247)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 11:40:24 +00:00
Skye Elliot 3ceadd512d Refactor history sharing tests using setupClients helper (#5235)
* tests: Refactor history sharing tests using `setupClients` helper

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

* tests: Use separate destructors for test clients

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-03-30 11:13:37 +00:00
renovate[bot] 8182180550 Update dependency happy-dom to v20.8.9 [SECURITY] (#5244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 09:09:17 +00:00
renovate[bot] aed74c5a72 Update dependency happy-dom to v20.8.8 [SECURITY] (#5243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-27 20:09:41 +00:00
renovate[bot] 8c259c53a6 Update actions/deploy-pages action to v5 (#5238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 20:06:08 +00:00
Michael Telatynski 4d59291538 Disable Complement Crypto tests (#5242)
Disabled Complement Crypto tests due to flakiness.
2026-03-27 17:38:37 +00:00
Michael Telatynski 80009a1b31 Add support for non-root package.json for version calculation in Sonar workflow (#5240)
* Add support for non-root package.json for version calculation in Sonar workflow

* Fix version_cmd
2026-03-27 09:16:01 +00:00
renovate[bot] 93e6c95953 Update dependency typescript to v6 (#5234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 03:16:33 +00:00
renovate[bot] 27a5507cef Update dependency knip to v6 (#5237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 02:41:41 +00:00
renovate[bot] be06f6655e Update dependency typedoc to v0.28.18 (#5236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 22:56:30 +00:00
Skye Elliot 71152f33bf Rotate the current room key when we see a member leave (#5231)
* feat: Rotate room key when a member leaves the room

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

* test: Assert room key rotated to prevent MSC4268 leaking keys

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

* docs: Outline key rotation scenario above discard logic

* feat: Use `RoomStateEvents.Events` over membership event

* docs: Correct spelling in scenario explanation

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* docs: Pull scenario explanation up to `onRoomStateEvent`

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

* tests: Assert room key is rotated under leave va gappy sync

* tests: Build sync response incrementally for gappy/ungappy sync

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-25 16:39:58 +00:00
RiotRobot bc8f67089c Merge branch 'master' into develop 2026-03-24 11:22:32 +00:00
RiotRobot acc9aa8939 v41.2.0 2026-03-24 11:21:57 +00:00
Richard van der Hoff e76f627fe3 Add some docs to the DeviceIsolationModes (#5232)
* Add some docs to the DeviceIsolationModes

Notes to help us/me remember how these relate to MSC4153.

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2026-03-23 15:29:35 +00:00
renovate[bot] 45b1e73842 Update npm non-major dependencies (#5229)
* Update npm non-major dependencies

* Update test

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>
2026-03-20 09:58:04 +00:00
renovate[bot] f3eefd2f32 Update element-hq/element-meta digest to 7f2f93f (#5222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 09:27:19 +00:00
renovate[bot] f7c053216b Update dependency eslint-plugin-jsdoc to v62.8.0 (#5228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 09:18:31 +00:00
renovate[bot] 9c7739f14f Update actions/download-artifact digest to 3e5f45b (#5220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 09:10:38 +00:00
renovate[bot] 897afe153a Update pnpm/action-setup digest to fc06bc1 (#5225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 19:03:49 +00:00
renovate[bot] a929391dcd Update shogo82148/actions-upload-release-asset digest to 96bc1f0 (#5226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 18:06:53 +00:00
renovate[bot] 45c5ee9f65 Update actions/setup-node digest to 53b8394 (#5221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 16:11:41 +00:00
renovate[bot] e56aaa16c7 Update mheap/github-action-required-labels digest to 0ac283b (#5224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 15:47:21 +00:00
renovate[bot] da0d3d791e Update element-hq/element-web digest to 9730933 (#5223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 15:04:32 +00:00
renovate[bot] ed5eb670a1 Update zizmorcore/zizmor-action action to v0.5.2 (#5227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 14:27:14 +00:00
renovate[bot] b7fcb6e4c1 Update pnpm to v10.32.1 (#5230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 14:19:06 +00:00
RiotRobot bd775f6b61 v41.2.0-rc.0 2026-03-17 11:39:31 +00:00
Skye Elliot c2f9ad28fc Only share history if room history visibility is shared (#5216)
* feat: Only share history if room history visibility is shared

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

* docs: Update documentation for `InviteOpts.shareEncryptedHistory`

* tests: Ensure shared history respects current history visibility

This commit additionally modifies `expectSendRoomEvent` to remove
the matcher on success, since fetchmock takes a while to do this
automatically.

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

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-03-16 13:03:41 +00:00
Richard van der Hoff 7f33e3462e History sharing: resume key-bundle import on restart (#5214)
* Store rooms pending key bundles in the CryptoStore

Replace the in-memory storage of which rooms are waiting for a key bundle with
permanent storage in the crypto store.

* Clear pending-key-bundle flag on malformed bundles

If we cannot import the key bundle, there is no point trying again another
time: we may as well clear the flag either way.

* Factor out some helpers in history sharing integ test

* Do not accept key bundles for rooms we joined more than 24h ago

Per discussion in crypto-internal.

* Clear pending key bundle data when we leave a room

* Resume key-bundle import on restart

* Clear pending-key-bundle flag on rooms that we joined ages ago

* fixup! Clear pending-key-bundle flag on malformed bundles
2026-03-16 11:58:36 +00:00
Richard van der Hoff d99363d288 Move shareRoomHistoryWithUser to CryptoBackend (#5218)
There is no need for this method to be exposed to the application, and it's a
footgun waiting to trap the unwary user.

It's marked `@experimental` so we're allowed to move it without a major version
bump.
2026-03-12 21:21:31 +00:00
renovate[bot] 3642b99212 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v18 (#5217)
* Update dependency @matrix-org/matrix-sdk-crypto-wasm to v18

* Adapt to breaking changes in rust-sdk wasm bindings

* more types fixes

* types fixes for tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2026-03-12 18:16:44 +00:00
Valere Fedronic 6ec0987286 re export sticky event types (#5213)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2026-03-12 17:27:11 +00:00
RiotRobot 219eb617dc Merge branch 'master' into develop 2026-03-10 13:46:12 +00:00
RiotRobot c6f9b25046 v41.1.0 2026-03-10 13:45:36 +00:00
Michael Telatynski 5d0e2efaf3 Add zizmor CI & make it happy (#5212)
* Add zizmor CI & make it happy

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

* Fix additional zizmor warning

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-04 09:27:47 +00:00
renovate[bot] c7cd5570d3 Update eslint-plugins (#5207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 14:01:28 +00:00
renovate[bot] c2f6dd2ce0 Update crazy-max/ghaction-import-gpg action to v7 (#5210)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:24:07 +00:00
renovate[bot] 393732aaae Update npm non-major dependencies (#5208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:23:47 +00:00
renovate[bot] d373fd8540 Update GitHub Artifact Actions (#5211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:22:31 +00:00
renovate[bot] 44a8a9a47a Update pnpm to v10.30.3 (#5209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:22:19 +00:00
RiotRobot 8a0b7ad68b v41.1.0-rc.0 2026-03-03 13:12:49 +00:00
Andy Balaam 09663302e1 Throw a specific error when the backup decryption key does not match the public backup (#5202) 2026-03-02 13:57:25 +00:00
dependabot[bot] 94f83b702c Bump rollup from 4.57.1 to 4.59.0 (#5203)
Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 13:48:20 +00:00
Michael Telatynski 9df27ee672 Fix downstream tsc (#5204)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-02 13:29:42 +00:00
Michael Telatynski 5739b59faa Update release workflows to deal with monorepos (#5201)
* Update release workflows to deal with monorepos

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

* Do the same for release-gitflow.yml

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-25 08:58:06 +00:00
Valere Fedronic e4425570c7 cleanup: Remove deprecated rtc room key transport (#5119)
* cleanup: Remove deprecated rtc room key transport

* fix: rtc statistics are managed by transport directly

* mark as readonly

* cleanup do not use deprecated `room`

* doc: Add missing param doc

* fixup: add back test wrongly removed
2026-02-24 13:32:08 +00:00
RiotRobot 145cb26054 Merge branch 'master' into develop 2026-02-24 13:59:12 +00:00
RiotRobot 26d5b1cde2 v41.0.0 2026-02-24 13:58:36 +00:00
renovate[bot] 0666d6b4e1 Update dependency typedoc to v0.28.17 (#5196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 11:23:34 +00:00
Michael Telatynski 9002064f10 Remove rimraf in favour of --delete-dir-on-start (#5200)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-24 11:01:56 +00:00
Will Hunt 5ea1554612 RTC Slots: Refactoring of membership event parsing and handling (#5134)
* Split out membership into seperate files.

* First pass of merging in new changes.

* More cleanup

* fix import

* Lots of test fixes

* remove skips

* unrelated change

* docstring

* comment

* lint lint lint

* copyright updates

* cleanup

* Ensure we await initial membership in all tests.

* fix race

* Use promises which are more reliable

* Even more promise stability

* cleanup

* Cleanup

* rename legacy.ts -> session.ts

* Update imports

* cleanup

* Rename files

* Rename + remove claimed_

* renaming

* Rename function

* All the cleanup

* tidy

* commit changes

* fix call membership

* fix claimed

* update slot_id

* fix device_id / claimed_device_id

* Update src/matrixrtc/utils.ts

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* use an aggregate error

* Export types

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2026-02-24 10:51:23 +00:00
Will Hunt bd6547c081 Update getUrlPreview to use /_matrix/client/v1/media/preview_url (#5191)
* Update preview_url endpoint.

* support <v1.11

* Add tests
2026-02-23 19:49:05 +00:00
Michael Telatynski 8073f27d98 Specify this type for BaseLogger methods (#5198)
This allows https://typescript-eslint.io/rules/unbound-method/ to be happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-19 23:05:15 +00:00
renovate[bot] 3bb22a9b28 Update npm non-major dependencies (#5197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 11:13:22 +00:00
renovate[bot] ba8bb3228d Update dependency eslint-plugin-jsdoc to v62.5.4 (#5195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 17:18:07 +00:00
renovate[bot] de23c9587b Update actions/checkout digest to de0fac2 (#5193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 17:17:30 +00:00
renovate[bot] 64eb482a49 Update actions/stale digest to b5d41d4 (#5194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 16:17:31 +00:00
RiotRobot aba7f8a0d4 v41.0.0-rc.0 2026-02-17 15:11:09 +00:00
RiotRobot 5495153c63 Merge remote-tracking branch 'origin/develop' into staging 2026-02-17 14:43:42 +00:00
Michael Telatynski 4f0696e2a4 Revert "Disable complement crypto tests temporarily (#5188)" (#5190)
This reverts commit 327d2fa7c8.
2026-02-17 11:13:05 +00:00
Bas Nijholt 0e659d294e fix(relations): prevent stale m.replace from overriding newer edits (#5192)
When multiple m.replace edits arrive concurrently, getLastReplacement()
may block on decryption. If an older edit's decryption completes after a
newer edit has already been applied, the older async result overwrites
the target event with stale content.

Add a monotonic update counter (replacementUpdateId) and centralise all
replacement updates through updateTargetEventReplacement(). The method
captures the counter before awaiting and discards the result if a newer
update has started in the meantime.

This race is especially pronounced in encrypted rooms with rapid
streaming-style edits, where variable decryption timing causes
out-of-order promise resolution.
2026-02-17 09:47:08 +00:00
Skye Elliot e74eb4928e Download room keys from backup prior to buliding historic room key bundles (#5171)
* chore: Update `@matrix-org/matrix-sdk-crypto-wasm` to v17.1.0

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

* feat: Download keys from key backup.

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

* tests: Ensure backup is downloaded before building room key bundle.

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

* fix: Address review comments in history sharing tests.

* docs: Improve `getSyncResponse` and `assertInviteAndShareHistory` docs

* feat: Log `backupVersion`, `hasDecryptionKey` on download failure

* fix: Group backup data, add casts to `generate-test-data.py`.

* tests: Update `getSyncResponse` calls in history sharing integ tests

* fix: Add history visibility argument to state events test.

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-02-13 13:31:14 +00:00
David Baker 327d2fa7c8 Disable complement crypto tests temporarily (#5188)
* Disable complement crypto tests temporarily

As per comment

* and the downstream one too

* Stub downstream test instead

* prettier
2026-02-12 14:57:34 +00:00
Aditya Cherukuru 028357f15f Fix reactive display name disambiguation (#5135)
* Fix reactive display name disambiguation

When a room member changes their display name, recalculate the disambiguation flag for all other members who share (or previously shared) that display name. This ensures that the 'disambiguate' flag is updated reactively when display name conflicts appear or are resolved.

Fixes element-hq/element-web#468

Fixes element-hq/element-web#4795

Fixes element-hq/element-web#31551

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

* Refactor: move disambiguation logic per review feedback

- Added updateDisambiguation() method to RoomMember for direct disambiguation recalculation

- Moved affected display name tracking to setStateEvents() instead of updateDisplayNameCache()

- Removed setMembershipEvent() hack, now calls updateDisambiguation() directly

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

* Exclude processed members from disambiguation loop

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

---------

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>
2026-02-12 14:23:04 +00:00
Michael Telatynski 872ec6755e Add support for Matrix Spec v1.13 (#5160) 2026-02-11 16:14:02 +00:00
RiotRobot 333d6a7bd6 v40.3.0-rc.0 2026-02-11 15:06:14 +00:00
Michael Telatynski 47532de452 Switch from yarn classic to pnpm (#5184) 2026-02-11 10:35:25 +00:00
RiotRobot 87e1049dae Merge branch 'master' into develop 2026-02-10 15:17:11 +00:00
RiotRobot 4c8e38009f v40.2.0 2026-02-10 15:16:24 +00:00
Timo 6e3efef0c5 Fix empty string to room compatibility trick to only apply to m.call (#5172)
* Fix empty string to room compatibility trick to only apply to m.call

* add logging

* fix linter

* Add tests

* limit logging.
2026-02-08 11:25:34 +00:00
Olivier 'reivilibre fb590627bb Add logging on MSC4108 DELETE request (#5140)
Just noticed these requests aren't logged,
which makes debugging difficult.
This is very drive-by, done in the web editor.

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2026-02-05 10:13:44 +00:00
Michael Telatynski 24cc17c270 Merge remote-tracking branch 'origin/develop' into develop 2026-02-04 11:47:05 +00:00
Michael Telatynski 68084e8fc3 Fix vitest slow reporter crashing CI
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-04 11:46:48 +00:00
Richard van der Hoff 0c3bb1f246 Add m.invite_permission_config account data type (#5183)
For MSC4380
2026-02-04 11:00:16 +00:00
renovate[bot] 7f42b67f68 Update matrix-org (#5181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 15:11:44 +00:00
renovate[bot] 9b871ac969 Update dependency eslint-plugin-jsdoc to v62.5.0 (#5179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:39:51 +00:00
renovate[bot] 49f7972a9e Update peter-evans/repository-dispatch digest to 28959ce (#5177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:16:42 +00:00
renovate[bot] c5ae4c8c0d Update dependency typedoc-plugin-mdn-links to v5.1.1 (#5180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:02:42 +00:00
renovate[bot] 2423300acd Update npm non-major dependencies (#5178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:01:03 +00:00
renovate[bot] 6cafa175b8 Update guibranco/github-status-action-v2 digest to 9bfa877 (#5176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 13:55:10 +00:00
RiotRobot 40165942e3 v40.2.0-rc.0 2026-02-03 13:14:44 +00:00
Hugh Nimmo-Smith f301251ff5 Clean up the ValidatedAuthMetadata types (#5175)
We don't expect oidc-client-ts to provide the `device_authorization_endpoint` in the `OidcMetadata` because it isn't part of the OIDC spec.

As such, I think it makes sense to standardise on defining the metadata fields in `validate.ts` and clarify where they come from.
2026-02-02 17:27:33 +00:00
Hugh Nimmo-Smith 21cd5e98c1 Use stable /auth_metadata endpoint where homeserver supports v1.15 (#5174) 2026-02-02 15:25:48 +00:00
Andy Balaam db070dca57 Support additional_creators in upgradeRoom (MSC4289) (#5173)
* Support additional_creators in upgradeRoom (MSC4289)

Signed-off-by: Andy Balaam <andy.balaam@matrix.org>

* Remove unneeded undefined in type definition

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Andy Balaam <andy.balaam@matrix.org>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-02 11:24:53 +00:00
Timo 8b6ff0abcb [js sdk embedded/widget] Fix race where this.syncApi.injectRoomEvents was called before the syncApi is instantiated (#5168)
* Make sure we do not call this.syncApi.injectRoomEvents before the
syncApi is instanciated

* sonarCube remove complexity (embedded.js constructor)
2026-01-29 16:22:41 +00:00
Timo f2157f28bb [MatrixRTC] Minimal change to transition from "" to "ROOM" as the callId/slotId (#5166)
* Minimal change to transition from "" to "ROOM" as the callId/slotId

* Also transition MembershipManager tests to `"ROOM"`

* fix merge
2026-01-29 15:37:34 +00:00
Timo 739b8e1f89 Remove sending of deprecated notify event (we now use (#5167)
`m.rtc.notification`
2026-01-27 15:52:41 +00:00
Timo bc57a3f829 [MatrixRTC] Do not sent the livekit_alias in sticky events (#5165)
* Do not sent the livekit_alias in sticky events

* tests
2026-01-27 15:23:14 +00:00
RiotRobot f136f6ddf7 Merge branch 'master' into develop 2026-01-27 12:39:59 +00:00
R Midhun Suresh 82b51d0d46 Improve startup performance by using promise.all when processing rooms from sync (#5095)
* Use Promise.all when processing rooms in sync response

* Remove import
2026-01-27 08:56:26 +00:00
David Baker fb12a5a1d6 Recalculate room name on loading members (#5158)
* Recalculate room name on loading members

Because if it's a DM room, loading members might change the room name

* Swap other userA / userB constants

* Typo

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-01-26 12:25:41 +00:00
Hugh Nimmo-Smith 61ea5a7dfc Add OAuthGrantType enum for OAuth 2.0 API grant types (#5161)
* Add OAuthGrantType enum for OAuth 2.0 API grant types

* Resolve circular imports

* Fix complication error
2026-01-23 13:46:56 +00:00
Hugh Nimmo-Smith 4e032317fe Add support for stable OAuth2.0 aware feature from MSC3824 (#5159)
* Add support for stable OAuth2.0 aware feature from MSC3824

* Use stable name internally

* Mark DELEGATED_OIDC_COMPATIBILITY as

* Add tsdoc config for @alias JSDoc modifier
2026-01-23 09:50:12 +00:00
Robin dbb2ae5c07 Give RoomWidgetClient the ability to send and receive sticky events (#5142)
* Give RoomWidgetClient the ability to send and receive sticky events

* linter

* Fix existing tests

* Add tests for sticky event support in embedded clients

* Update sticky event widget capability identifiers

In matrix-widget-api 0.16.1 they are updated to use the new unstable prefix from MSC4407.

* Explicitly require matrix-widget-api ≥ 1.16.1

* remove TODO comment

* simplify type lint checks
This is needed for EW donwstream tests. Otherwise it will through:
Error: matrix-js-sdk/src/embedded.ts(417,21): error TS2345: Argument of
type 'string | number | boolean | string[]' is not assignable to
parameter of type 'number'.

---------

Co-authored-by: Timo K <toger5@hotmail.de>
2026-01-22 16:07:13 +00:00
Michael Telatynski c8032a214e Typescript fixes (#5157)
* Remove deprecated tsconfig baseUrl

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

* Fix imports in tests

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

* Fix duplicated map entry

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-01-21 17:28:10 +00:00
Timo 6e34ca6f2d [MatrixRTC] Fix delayId not resetting on leave (#5156)
* reset delay id to undefined after sucessfully sending it.

* add tests

* setAndEmitDelayId signature
dont allow implicit undefined
2026-01-21 13:53:55 +00:00
Andy Balaam 4a7a699623 Unit tests for OutgoingRequestsManager not repeating failed requests (#5154) 2026-01-21 12:03:09 +00:00
renovate[bot] 774776178e Update npm non-major dependencies (#5150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 15:08:00 +00:00
renovate[bot] ed4078528d Update dependency typedoc to v0.28.16 (#5149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 15:03:52 +00:00
renovate[bot] 7224961e9e Update actions/setup-node digest to 6044e13 (#5147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:44:56 +00:00
renovate[bot] 35ca07e8fb Update matrix-org/sonarcloud-workflow-action digest to ea0cd9d (#5148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:44:47 +00:00
renovate[bot] ccffb5df2d Update dependency eslint-plugin-jsdoc to v62 (#5153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:44:04 +00:00
renovate[bot] 0d162f66f3 Update dependency matrix-widget-api to v1.16.0 (#5152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:43:47 +00:00
117 changed files with 13253 additions and 9555 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
* @matrix-org/element-web-reviewers
/.github/workflows/** @matrix-org/element-web-team
/package.json @matrix-org/element-web-team
/yarn.lock @matrix-org/element-web-team
/pnpm-lock.yaml @matrix-org/element-web-team
/scripts/** @matrix-org/element-web-team
/src/webrtc @matrix-org/element-call-reviewers
/src/matrixrtc @matrix-org/element-call-reviewers
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ env.VERSION }}.tar.gz.asc
@@ -29,13 +29,13 @@ runs:
- name: Upload asset signatures
if: inputs.gpg-fingerprint
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+3 -1
View File
@@ -1,6 +1,8 @@
name: Backport
on:
pull_request_target:
# Privilege escalation necessary to enable backporting PRs from forks
# 🚨 We must not execute any checked out code here.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- closed
- labeled
+4 -2
View File
@@ -1,7 +1,9 @@
name: Deploy documentation PR preview
on:
workflow_run:
# Privilege escalation necessary to publish to Netlify
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Static Analysis"]
types:
- completed
@@ -15,7 +17,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -1,7 +1,7 @@
# Triggers after the "Downstream artifacts" build has finished, to run the
# matrix-react-sdk playwright tests (with access to repo secrets)
# element-web playwright tests (with access to repo secrets)
name: matrix-react-sdk End to End Tests
name: Element Web End to End Tests
on:
merge_group:
types: [checks_requested]
@@ -21,11 +21,12 @@ concurrency:
jobs:
playwright:
name: Playwright
uses: element-hq/element-web/.github/workflows/end-to-end-tests.yaml@develop
uses: element-hq/element-web/.github/workflows/build-and-test.yaml@develop # zizmor: ignore[unpinned-uses]
permissions:
actions: read
issues: read
pull-requests: read
contents: read
with:
matrix-js-sdk-sha: ${{ github.sha }}
# We only want to run the playwright tests on merge queue to prevent regressions
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
- name: Notify element-web repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+8 -5
View File
@@ -1,6 +1,9 @@
name: Pull Request
on:
pull_request_target:
# Privilege escalation necessary access members of the review teams
# 🚨 We must not execute any checked out code here, and be careful around use of user-controlled inputs.
# FIXME: only `community-prs` job needs this privilege, so it should be in its own workflow file.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled, unlabeled, synchronize]
merge_group:
types: [checks_requested]
@@ -15,7 +18,7 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-24.04
steps:
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
if: github.event_name != 'merge_group'
with:
labels: |
@@ -35,7 +38,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -60,7 +63,7 @@ jobs:
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
github.rest.issues.addLabels({
@@ -81,7 +84,7 @@ jobs:
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
github.rest.issues.createComment({
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check for X-Release-Blocker label on any open issues or PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
REPO: ${{ inputs.repository }}
with:
@@ -16,18 +16,20 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: package.json
cache: "yarn"
cache: "pnpm"
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- uses: t3chguy/release-drafter@105e541c2c3d857f032bd522c0764694758fabad
id: draft-release
@@ -37,7 +39,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -48,7 +50,7 @@ jobs:
- name: Ingest upstream changes
if: inputs.include-changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
+1 -1
View File
@@ -13,4 +13,4 @@ jobs:
draft:
permissions:
contents: write
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop # zizmor: ignore[unpinned-uses]
+13 -6
View File
@@ -12,20 +12,25 @@ on:
description: List of dependencies to reset.
type: string
required: false
dir:
description: The directory to release
type: string
default: "."
concurrency: ${{ github.workflow }}
permissions: {} # Uses ELEMENT_BOT_TOKEN
jobs:
merge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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
persist-credentials: true
- name: Get actions scripts
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -33,13 +38,14 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Set up git
run: |
@@ -53,6 +59,7 @@ jobs:
- name: Reset dependencies
if: inputs.dependencies
working-directory: ${{ inputs.dir }}
run: |
while IFS= read -r PACKAGE; do
[ -z "$PACKAGE" ] && continue
@@ -73,7 +80,7 @@ jobs:
fi
echo "Resetting $PACKAGE to develop branch..."
yarn add "github:matrix-org/$PACKAGE#develop"
pnpm add "github:matrix-org/$PACKAGE#develop"
git add -u
git commit -m "Reset $PACKAGE back to develop branch"
done <<< "$DEPENDENCIES"
+39 -19
View File
@@ -26,12 +26,21 @@ on:
description: |
The path to the asset you want to upload, if any. You can use glob patterns here.
Will be GPG signed and an `.asc` file included in the release artifacts if `gpg-fingerprint` is set.
Relative to `dir`.
type: string
required: false
expected-asset-count:
description: The number of expected assets, including signatures, excluding generated zip & tarball.
type: number
required: false
dist-dir:
description: The directory to release
type: string
default: "."
version-dirs:
description: Directories in which to update package.json `version` field
type: string
required: false
outputs:
npm-id:
description: "The npm package@version string we published"
@@ -43,7 +52,7 @@ jobs:
permissions:
issues: read
pull-requests: read
uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop # zizmor: ignore[unpinned-uses]
release:
name: Release
@@ -56,7 +65,7 @@ jobs:
- name: Load GPG key
id: gpg
if: inputs.gpg-fingerprint
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
@@ -64,22 +73,23 @@ jobs:
- name: Get draft release
id: draft-release
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # 1.2.5
env:
GITHUB_TOKEN: ${{ github.token }}
with:
draft: true
latest: true
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
persist-credentials: true
- name: Get actions scripts
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -90,6 +100,7 @@ jobs:
- name: Prepare variables
id: prepare
working-directory: ${{ inputs.dist-dir }}
run: |
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -104,7 +115,7 @@ jobs:
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { VERSION } = process.env;
@@ -123,15 +134,17 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
node-version-file: package.json
cache: "pnpm"
node-version-file: ${{ inputs.dist-dir }}/package.json
- name: Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Handle develop dependencies
working-directory: ${{ inputs.dist-dir }}
run: |
ret=0
cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do
@@ -140,15 +153,19 @@ jobs:
VERSION=${dep[1]}
echo "::warning title=Develop dependency found::$DEPENDENCY will be kept at $VERSION"
yarn upgrade "$PACKAGE@$VERSION" --exact
pnpm add "$PACKAGE@$VERSION" --save-exact
git add -u
git commit -m "Keep $PACKAGE at $VERSION"
done
- name: Bump package.json version
- name: Bump package.json versions
run: |
yarn version --no-git-tag-version --new-version "${VERSION#v}"
git add package.json
for DIR in $DIRS; do
pnpm version -C "$DIR" --no-git-tag-version "${VERSION#v}"
git add "$DIR"/package.json
done
env:
DIRS: ${{ inputs.version-dirs || inputs.dist-dir }}
- name: Add to CHANGELOG.md
if: inputs.final
@@ -175,7 +192,8 @@ jobs:
- name: Build assets
if: steps.prepare.outputs.has-dist-script == '1'
run: DIST_VERSION="$VERSION" yarn dist
working-directory: ${{ inputs.dist-dir }}
run: DIST_VERSION="$VERSION" pnpm dist
- name: Upload release assets & signatures
if: inputs.asset-path
@@ -183,7 +201,7 @@ jobs:
with:
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
upload-url: ${{ steps.draft-release.outputs.upload_url }}
asset-path: ${{ inputs.asset-path }}
asset-path: ${{ inputs.dist-dir }}/${{ inputs.asset-path }}
- name: Create signed tag
if: inputs.gpg-fingerprint
@@ -216,7 +234,7 @@ jobs:
- name: Validate release has expected assets
if: inputs.expected-asset-count
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
@@ -244,7 +262,7 @@ jobs:
git push origin master
- name: Publish release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }}
@@ -276,7 +294,9 @@ jobs:
name: Publish to npm
needs: release
if: inputs.npm
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop # zizmor: ignore[unpinned-uses]
with:
dir: ${{ inputs.dist-dir }}
permissions:
contents: read
id-token: write
+14 -6
View File
@@ -1,6 +1,11 @@
name: Publish to npm
on:
workflow_call:
inputs:
dir:
description: The directory to release
type: string
default: "."
outputs:
id:
description: "The npm package@version string we published"
@@ -17,26 +22,29 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
persist-credentials: false
- name: 🔧 Yarn cache
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
node-version-file: package.json
node-version-file: ${{ inputs.dir }}/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"
run: "pnpm install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
working-directory: ${{ inputs.dir }}
run: |
npm publish --provenance --access public --tag "$TAG"
release=$(jq -r '"\(.name)@\(.version)"' package.json)
+24 -17
View File
@@ -24,7 +24,7 @@ concurrency: ${{ github.workflow }}
permissions: {} # No permissions required
jobs:
release:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop # zizmor: ignore[unpinned-uses,secrets-inherit]
permissions:
contents: write
issues: write
@@ -41,28 +41,32 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
repo:
- element-hq/element-web
include:
- repo: element-hq/element-web
path: apps/web
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
persist-credentials: true
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: "lts/*"
- name: Bump dependency
env:
DEPENDENCY: ${{ needs.release.outputs.npm-id }}
DIR: ${{ matrix.path }}
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
yarn upgrade "$DEPENDENCY" --exact
git add package.json yarn.lock
pnpm add -C "$DIR" "$DEPENDENCY" --save-exact
git add "$DIR"/package.json pnpm-lock.yaml
git commit -am"Upgrade dependency to $DEPENDENCY"
git push origin staging
@@ -73,22 +77,25 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: 🔧 Yarn cache
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: 📖 Generate docs
run: yarn gendoc
run: pnpm gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: _docs
@@ -106,4 +113,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
+12 -8
View File
@@ -13,6 +13,10 @@ on:
type: boolean
required: false
description: "Whether to combine multiple LCOV and sonar-report files in coverage artifact"
version-pkg-json-dir:
type: string
default: "."
description: "Relative path of the directory containing package.json with the `version` to use."
permissions: {}
jobs:
sonarqube:
@@ -27,7 +31,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -36,14 +40,15 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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
persist-credentials: false
- name: 📥 Download artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -51,14 +56,13 @@ jobs:
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: coverage-*
path: coverage
merge-multiple: true
- name: Check coverage artifact
run: |
if [ ! -d coverage ]; then
@@ -75,7 +79,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@9f6f057c1b30500877460c66d4ae937e53d168ee
uses: matrix-org/sonarcloud-workflow-action@13968a27c924fa19b1dacbce6ca3ff217daa775b
# 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:
@@ -83,12 +87,12 @@ jobs:
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"
version_cmd: "cat ${{ inputs.version-pkg-json-dir }}/package.json | jq -r .version"
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+4 -2
View File
@@ -1,6 +1,8 @@
name: SonarQube
on:
workflow_run:
# Privilege escalation necessary to call upon SonarCloud
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Tests"]
types:
- completed
@@ -16,7 +18,7 @@ jobs:
actions: read
statuses: write
id-token: write # sonar
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop # zizmor: ignore[unpinned-uses]
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+79 -53
View File
@@ -14,58 +14,61 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Typecheck
run: "yarn run lint:types"
run: "pnpm run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Run Linter
run: "yarn run lint:js"
run: "pnpm run lint:js"
node_example_lint:
name: "Node.js example"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Build Types
run: "yarn build:types"
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "npm"
node-version-file: "examples/node/package.json"
# cache-dependency-path: '**/package-lock.json'
run: "pnpm build:types"
- name: Install Example Deps
run: "npm install"
@@ -82,39 +85,50 @@ jobs:
workflow_lint:
name: "Workflow Lint"
runs-on: ubuntu-24.04
permissions:
security-events: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Run Linter
run: "yarn lint:workflows"
run: "pnpm lint:workflows"
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
docs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Generate Docs
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docs
path: _docs
@@ -125,31 +139,36 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Run linter
run: "yarn run lint:knip"
run: "pnpm run lint:knip"
element-web:
name: Downstream tsc element-web
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: element-hq/element-web
persist-credentials: false
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: "lts/*"
- name: Install Dependencies
@@ -159,15 +178,22 @@ jobs:
JS_SDK_GITHUB_BASE_REF: ${{ github.sha }}
- name: Typecheck
run: "yarn run lint:types"
working-directory: apps/web
run: "pnpm run lint:types"
# Hook for branch protection to skip downstream typechecking outside of merge queues
downstream:
name: Downstream Typescript Syntax Check
# Workflow consolidation job
done:
needs:
- ts_lint
- js_lint
- node_example_lint
- workflow_lint
- docs
- analyse_dead_code
- element-web
name: Static Analysis
runs-on: ubuntu-24.04
if: always()
needs:
- element-web
steps:
- if: needs.element-web.result != 'skipped' && needs.element-web.result != 'success'
- if: contains(needs.*.result , 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+1 -1
View File
@@ -11,7 +11,7 @@ on:
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
sync-labels:
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@develop
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
with:
LABELS: |
element-hq/element-meta
+19 -13
View File
@@ -22,17 +22,20 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node
id: setupNode
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: ${{ matrix.node }}
- name: Install dependencies
run: "yarn install"
run: "pnpm install"
- name: Get number of CPU cores
id: cpu-cores
@@ -40,20 +43,23 @@ jobs:
- name: Run tests
run: |
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--maxWorkers ${{ steps.cpu-cores.outputs.count }} \
pnpm test \
--coverage=${ENABLE_COVERAGE} \
--maxWorkers ${NUM_WORKERS} \
./spec/${{ matrix.specs }}
env:
SHARD: ${{ matrix.specs }}
NUM_WORKERS: ${{ steps.cpu-cores.outputs.count }}
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
run: mv coverage/lcov.info coverage/${NODE_VERSION}-${{ matrix.specs }}.lcov.info
env:
NODE_VERSION: ${{ steps.setupNode.outputs.node-version }}
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
@@ -73,7 +79,7 @@ jobs:
element-web:
name: Downstream test element-web
if: github.event_name == 'merge_group'
uses: element-hq/element-web/.github/workflows/tests.yml@develop
uses: element-hq/element-web/.github/workflows/tests.yml@develop # zizmor: ignore[unpinned-uses]
permissions:
statuses: write
with:
@@ -83,8 +89,8 @@ jobs:
complement-crypto:
name: "Run Complement Crypto tests"
if: github.event_name == 'merge_group'
permissions: read-all
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main
permissions: read-all # zizmor: ignore[excessive-permissions]
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main # zizmor: ignore[unpinned-uses]
with:
use_js_sdk: "."
@@ -112,7 +118,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
automate-project-columns-next:
runs-on: ubuntu-24.04
steps:
- uses: actions/add-to-project@main
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/120
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+1 -1
View File
@@ -6,6 +6,6 @@ on:
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
call-triage-labelled:
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@develop
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@6339bcda15c71d209303b18a06a9b1c021220bf9
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
with:
operations-per-run: 250
days-before-issue-stale: -1
+1
View File
@@ -3,6 +3,7 @@
/.npmrc
/*.log
pnpm-lock.yaml
package-lock.json
.lock-wscript
build/Release
+67
View File
@@ -1,3 +1,70 @@
Changes in [41.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.3.0) (2026-04-07)
==================================================================================================
## 🐛 Bug Fixes
* Rotate the current room key when we see a member leave ([#5231](https://github.com/matrix-org/matrix-js-sdk/pull/5231)). Contributed by @kaylendog.
Changes in [41.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.2.0) (2026-03-24)
==================================================================================================
## ✨ Features
* Only share history if room history visibility is shared ([#5216](https://github.com/matrix-org/matrix-js-sdk/pull/5216)). Contributed by @kaylendog.
* History sharing: resume key-bundle import on restart ([#5214](https://github.com/matrix-org/matrix-js-sdk/pull/5214)). Contributed by @richvdh.
* Move `CryptoApi.shareRoomHistoryWithUser` to `CryptoBackend` ([#5218](https://github.com/matrix-org/matrix-js-sdk/pull/5218)). Contributed by @richvdh.
Changes in [41.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.1.0) (2026-03-10)
==================================================================================================
## ✨ Features
* Throw a specific error when the backup decryption key does not match the public backup ([#5202](https://github.com/matrix-org/matrix-js-sdk/pull/5202)). Contributed by @andybalaam.
* Update getUrlPreview to use /\_matrix/client/v1/media/preview\_url ([#5191](https://github.com/matrix-org/matrix-js-sdk/pull/5191)). Contributed by @Half-Shot.
Changes in [41.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.0.0) (2026-02-24)
==================================================================================================
## 🚨 BREAKING CHANGES
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
## ✨ Features
* Download room keys from backup prior to buliding historic room key bundles ([#5171](https://github.com/matrix-org/matrix-js-sdk/pull/5171)). Contributed by @kaylendog.
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
* Add logging on MSC4108 DELETE request ([#5140](https://github.com/matrix-org/matrix-js-sdk/pull/5140)). Contributed by @reivilibre.
* Add `m.invite_permission_config` account data type ([#5183](https://github.com/matrix-org/matrix-js-sdk/pull/5183)). Contributed by @richvdh.
## 🐛 Bug Fixes
* fix(relations): prevent stale m.replace from overriding newer edits ([#5192](https://github.com/matrix-org/matrix-js-sdk/pull/5192)). Contributed by @basnijholt.
* Fix reactive display name disambiguation ([#5135](https://github.com/matrix-org/matrix-js-sdk/pull/5135)). Contributed by @aditya-cherukuru.
* Fix empty string to room compatibility trick to only apply to m.call ([#5172](https://github.com/matrix-org/matrix-js-sdk/pull/5172)). Contributed by @toger5.
Changes in [40.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.2.0) (2026-02-10)
==================================================================================================
## 🦖 Deprecations
* [MatrixRTC] Remove sending of deprecated `notify` event (we now use `m.rtc.notification`) ([#5167](https://github.com/matrix-org/matrix-js-sdk/pull/5167)). Contributed by @toger5.
## ✨ Features
* Use stable /auth\_metadata endpoint where homeserver supports v1.15 ([#5174](https://github.com/matrix-org/matrix-js-sdk/pull/5174)). Contributed by @hughns.
* Support additional\_creators in upgradeRoom (MSC4289) ([#5173](https://github.com/matrix-org/matrix-js-sdk/pull/5173)). Contributed by @andybalaam.
* [MatrixRTC] Minimal change to transition from "" to "ROOM" as the callId/slotId ([#5166](https://github.com/matrix-org/matrix-js-sdk/pull/5166)). Contributed by @toger5.
* [MatrixRTC] Do not send the `livekit_alias` in sticky events ([#5165](https://github.com/matrix-org/matrix-js-sdk/pull/5165)). Contributed by @toger5.
* Improve startup performance by using `promise.all` when processing rooms from sync ([#5095](https://github.com/matrix-org/matrix-js-sdk/pull/5095)). Contributed by @MidhunSureshR.
* Add OAuthGrantType enum for OAuth 2.0 API grant types ([#5161](https://github.com/matrix-org/matrix-js-sdk/pull/5161)). Contributed by @hughns.
* Add support for stable OAuth2.0 aware feature from MSC3824 ([#5159](https://github.com/matrix-org/matrix-js-sdk/pull/5159)). Contributed by @hughns.
* Give RoomWidgetClient the ability to send and receive sticky events ([#5142](https://github.com/matrix-org/matrix-js-sdk/pull/5142)). Contributed by @robintown.
## 🐛 Bug Fixes
* [js sdk embedded/widget] Fix race where this.syncApi.injectRoomEvents was called before the syncApi is instantiated ([#5168](https://github.com/matrix-org/matrix-js-sdk/pull/5168)). Contributed by @toger5.
* [MatrixRTC] Fix delayId not resetting on leave ([#5156](https://github.com/matrix-org/matrix-js-sdk/pull/5156)). Contributed by @toger5.
Changes in [40.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.1.0) (2026-01-27)
==================================================================================================
## 🦖 Deprecations
+8 -8
View File
@@ -41,10 +41,10 @@ endpoints from before Matrix 1.1, for example.
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
Using `pnpm` instead of `npm` is recommended. Please see the pnpm [install
guide](https://pnpm.io/installation#using-corepack) if you do not have it already.
`yarn add matrix-js-sdk`
`pnpm add matrix-js-sdk`
```javascript
import * as sdk from "matrix-js-sdk";
@@ -310,7 +310,7 @@ This SDK uses [Typedoc](https://typedoc.org/guides/doccomments) doc comments. Yo
host the API reference from the source files like this:
```
$ yarn gendoc
$ pnpm gendoc
$ cd docs
$ python -m http.server 8005
```
@@ -453,7 +453,7 @@ want to use this SDK, skip this section._
First, you need to pull in the right build tools:
```
$ yarn install
$ pnpm install
```
## Building
@@ -461,17 +461,17 @@ First, you need to pull in the right build tools:
To build a browser version from scratch when developing:
```
$ yarn build
$ pnpm build
```
To run tests:
```
$ yarn test
$ pnpm test
```
To run linting:
```
$ yarn lint
$ pnpm lint
```
+1 -1
View File
@@ -21,4 +21,4 @@ export PATH="$rootdir/node_modules/.bin:$PATH"
# now run our checks
cd "$tmpdir"
yarn lint
pnpm lint
+3 -4
View File
@@ -1,5 +1,8 @@
import { KnipConfig } from "knip";
// Specify this as knip loads config files which may conditionally add reporters, e.g. `vitest-sonar-reporter'
process.env.GITHUB_ACTIONS = "1";
export default {
entry: [
"src/index.ts",
@@ -28,10 +31,6 @@ export default {
"husky",
// Used in script which only runs in environment with `@octokit/rest` installed
"@octokit/rest",
// Used by `vitest`
"vitest-sonar-reporter",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
+36 -24
View File
@@ -1,27 +1,26 @@
{
"name": "matrix-js-sdk",
"version": "40.1.0",
"version": "41.3.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"prepare": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"clean": "rimraf lib",
"build": "yarn clean && yarn build:compile && yarn build:types",
"prepare": "pnpm build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel --delete-dir-on-start src -w -s -d lib --verbose --extensions \".ts,.js\"",
"build": "pnpm build:compile && pnpm build:types",
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"build:compile": "babel --delete-dir-on-start -d lib --verbose --extensions \".ts,.js\" src",
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
"lint": "pnpm lint:types && pnpm lint:js && pnpm lint:workflows",
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
"lint:types": "tsc --noEmit",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"lint:knip": "knip",
"test": "vitest",
"test:watch": "vitest --watch",
"coverage": "yarn test --coverage"
"test": "vitest run",
"test:watch": "vitest watch",
"coverage": "pnpm test --coverage"
},
"repository": {
"type": "git",
@@ -49,19 +48,18 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.1.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.14.0",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "7",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "13"
"unhomoglyph": "^1.0.6"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -84,7 +82,7 @@
"@stylistic/eslint-plugin": "^5.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/node": "18",
"@types/node": "22",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
@@ -98,7 +96,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.0.0",
"eslint-plugin-jsdoc": "^62.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.5.0",
@@ -107,20 +105,34 @@
"fetch-mock": "^12.6.0",
"happy-dom": "^20.1.0",
"husky": "^9.0.0",
"knip": "^5.0.0",
"knip": "^6.0.0",
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "3.7.4",
"rimraf": "^6.0.0",
"prettier": "3.8.3",
"typedoc": "^0.28.1",
"typedoc-plugin-coverage": "^4.0.0",
"typedoc-plugin-mdn-links": "^5.0.0",
"typedoc-plugin-missing-exports": "^4.0.0",
"typescript": "^5.4.2",
"typescript": "^6.0.0",
"vitest": "^4.0.17",
"vitest-sonar-reporter": "^3.0.0"
},
"resolutions": {
"expect": "30.2.0"
}
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"eslint": "8"
}
},
"allowedDeprecatedVersions": {
"eslint": "8"
},
"overrides": {
"expect": "30.3.0",
"flatted@<=3.4.1": "^3.4.2",
"picomatch@>=4.0.0 <4.0.4": "^4.0.4",
"yaml@>=2.0.0 <2.8.3": "^2.8.3",
"vite": "8.0.8"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}
+8353
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
nodeLinker: hoisted
+110 -2
View File
@@ -86,6 +86,7 @@ import {
encryptGroupSessionKey,
encryptMegolmEvent,
encryptMegolmEventRawPlainText,
encryptOlmEvent,
establishOlmSession,
getTestOlmAccountKeys,
expectSendRoomKey,
@@ -2064,6 +2065,7 @@ describe("crypto", () => {
expect(hasCrossSigningKeysForUser).toBe(true);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
expect(verificationStatus.known).toBe(false); // We haven't actually stashed a copy of Alice's identity
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2078,7 +2080,8 @@ describe("crypto", () => {
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys(TEST_USER_ID);
expect(hasCrossSigningKeysForUser).toBe(true);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
expect(verificationStatus.known).toBe(true);
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2089,7 +2092,8 @@ describe("crypto", () => {
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys("@unknown:xyz");
expect(hasCrossSigningKeysForUser).toBe(false);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus("@unknown:xyz");
expect(verificationStatus.known).toBe(false);
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2119,6 +2123,7 @@ describe("crypto", () => {
{
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
expect(verificationStatus.known).toBe(true);
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2311,4 +2316,107 @@ describe("crypto", () => {
);
}
});
describe("secret pushing", () => {
it("should push a new backup key when a new backup key is set", async () => {
// setup: alice has another device, DEVICE_ID, which is verified
const crypto = aliceClient.getCrypto()!;
expectAliceKeyQuery(getTestKeysQueryResponse("@alice:localhost"));
await startClientAndAwaitFirstSync();
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
expect(devices.get("@alice:localhost")!.keys()).toContain("DEVICE_ID");
await crypto.setDeviceVerified("@alice:localhost", "DEVICE_ID");
expectAliceKeyClaim(getTestKeysClaimResponse("@alice:localhost"));
// when we set a new backup key
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
fetchMock.post("path:/_matrix/client/v3/room_keys/version", {
status: 200,
body: { version: "1" },
});
const secretPushPromise = new Promise<any>((resolve) => {
fetchMock.putOnce(new RegExp("/sendToDevice/m.room.encrypted/"), (callLog): RouteResponse => {
const content = JSON.parse(callLog.options.body as string);
resolve(content);
return {};
});
});
await crypto.resetKeyBackup();
// we expect the other device to get a secret push
const content = await secretPushPromise;
const curve25519key = JSON.parse(testOlmAccount.identity_keys()).curve25519;
const ciphertext = content.messages["@alice:localhost"].DEVICE_ID.ciphertext[curve25519key];
const olmSession = new Olm.Session();
olmSession.create_inbound(testOlmAccount, ciphertext.body);
const decrypted = JSON.parse(olmSession.decrypt(0, ciphertext.body));
expect(decrypted.type).toBe("io.element.msc4385.secret.push");
expect(decrypted.content.name).toBe("m.megolm_backup.v1");
});
it("should receive pushed backup key", async () => {
// setup: alice has another device, DEVICE_ID, which is verified,
// and has a key backup set up and signed by DEVICE_ID
const crypto = aliceClient.getCrypto()!;
expectAliceKeyQuery(getTestKeysQueryResponse("@alice:localhost"));
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
await startClientAndAwaitFirstSync();
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
expect(devices.get("@alice:localhost")!.keys()).toContain("DEVICE_ID");
await crypto.setDeviceVerified("@alice:localhost", "DEVICE_ID");
expectAliceKeyClaim(getTestKeysClaimResponse("@alice:localhost"));
// after we push the backup key to alice...
const senderIdentityKeys = JSON.parse(testOlmAccount.identity_keys());
const aliceDeviceKeys = await crypto.getOwnDeviceKeys();
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
const secretPush = encryptOlmEvent({
sender: "@alice:localhost",
senderKey: senderIdentityKeys.curve25519,
senderSigningKey: senderIdentityKeys.ed25519,
p2pSession,
recipient: "@alice:localhost",
recipientCurve25519Key: aliceDeviceKeys.curve25519,
recipientEd25519Key: aliceDeviceKeys.ed25519,
plaincontent: {
secret: testData.BACKUP_DECRYPTION_KEY_BASE64,
name: "m.megolm_backup.v1",
},
plaintype: "io.element.msc4385.secret.push",
});
const syncResponse = {
next_batch: 1,
to_device: {
events: [secretPush],
},
};
const backupKeyReceivedPromise = new Promise<string>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupDecryptionKeyCached, resolve);
});
const keyBackupEnabledPromise = new Promise<void>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
if (enabled) {
resolve();
}
});
});
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// alice should be using backup now
expect(await backupKeyReceivedPromise).toBe(testData.SIGNED_BACKUP_DATA.version);
await keyBackupEnabledPromise;
expect(await crypto.getActiveSessionBackupVersion()).toBe(testData.SIGNED_BACKUP_DATA.version);
});
});
});
File diff suppressed because it is too large Load Diff
+40 -10
View File
@@ -37,7 +37,13 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
import * as testData from "../../test-utils/test-data";
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
import { flushPromises } from "../../test-utils/flushPromises";
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
import {
decodeRecoveryKey,
DecryptionFailureCode,
CryptoEvent,
type CryptoApi,
DecryptionKeyDoesNotMatchError,
} from "../../../src/crypto-api";
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
const ROOM_ID = testData.TEST_ROOM_ID;
@@ -502,15 +508,10 @@ describe("megolm-keys backup", () => {
// @ts-ignore - mock a private method for testing purpose
vi.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64);
const fullBackup = {
rooms: {
[ROOM_ID]: {
sessions: {
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
},
},
},
};
const fullBackup = createFullBackup(
testData.MEGOLM_SESSION_DATA.session_id,
testData.CURVE25519_KEY_BACKUP_DATA,
);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage();
@@ -521,9 +522,38 @@ describe("megolm-keys backup", () => {
expect(result.imported).toStrictEqual(1);
});
it("Should throw an error if the decryption key does not match the backup", async function () {
// Given the stored backup decryption key does not match the public backup info
// @ts-ignore - mock a private method for testing purpose
vi.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64_ALT);
const fullBackup = createFullBackup(
testData.MEGOLM_SESSION_DATA.session_id,
testData.CURVE25519_KEY_BACKUP_DATA,
);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
// When we load that key, we throw because the keys don't match
await expect(aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage()).rejects.toThrow(
DecryptionKeyDoesNotMatchError,
);
});
it("Should throw an error if the decryption key is not found in cache", async () => {
await expect(aliceCrypto.restoreKeyBackup()).rejects.toThrow("No decryption key found in crypto store");
});
function createFullBackup(sessionId: string, data: KeyBackupSession) {
return {
rooms: {
[ROOM_ID]: {
sessions: {
[sessionId]: data,
},
},
},
};
}
});
describe("backupLoop", () => {
+8 -2
View File
@@ -23,7 +23,13 @@ import * as testUtils from "../../test-utils/test-utils";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
import { logger } from "../../../src/logger";
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
import {
createClient,
HistoryVisibility,
PendingEventOrdering,
type IStartClientOpts,
type MatrixClient,
} from "../../../src/matrix";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
@@ -197,7 +203,7 @@ describe("Encrypted State Events", () => {
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], HistoryVisibility.Joined, ROOM_ID, true));
await syncPromise(aliceClient);
// ... and claim one of Bob's OTKs ...
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { QrCodeData, QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "@fetch-mock/vitest";
import {
@@ -146,7 +146,7 @@ describe("MSC4108SignInWithQR", () => {
const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.fromBytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
await ourChannel.generateCode(QrCodeIntent.Reciprocate, client.getDomain()!),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
+26 -17
View File
@@ -84,9 +84,9 @@ def main() -> None:
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
import type {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import type {{ IDownloadKeyResult, IEvent }} from "../../../src";
import type {{ KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions }} from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
@@ -246,15 +246,6 @@ export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult>
/** Signed OTKs, returned by `POST /keys/claim` */
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Backup decryption key in export format */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const {prefix}MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = {
json.dumps(set_of_exported_room_keys, indent=4)
@@ -278,6 +269,23 @@ export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, ind
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, indent=4)};
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Backup decryption key in export format */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}`.
* Contains the key from {prefix}MEGOLM_SESSION_DATA.
*/
export const {prefix}PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {{
[{prefix}MEGOLM_SESSION_DATA.session_id]: {prefix}CURVE25519_KEY_BACKUP_DATA
}};
"""
alt_master_key = user_data.get("ALT_MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES")
@@ -385,7 +393,7 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
"""
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
that can be imported via importRoomKeys API.
that can be imported via importRoomKeys API, or shared via MSC4268 room history sharing.
Returns the exported key, the matching privat edKey (needed to encrypt)
"""
index = 0
@@ -409,11 +417,12 @@ def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tupl
"session_id": encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
),
"session_key": encode_base64(exported_key),
"session_key": encode_base64(bytes(exported_key)),
"sender_claimed_keys": {
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
},
"forwarding_curve25519_key_chain": [],
"m.shared_history": True,
}
return megolm_export, private_key
@@ -458,7 +467,7 @@ def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed2
"room_id": "!room:id",
"sender_key": previous["sender_key"],
"session_id": previous["session_id"],
"session_key": encode_base64(exported_key),
"session_key": encode_base64(bytes(exported_key)),
"sender_claimed_keys": previous["sender_claimed_keys"],
"forwarding_curve25519_key_chain": [],
}
@@ -609,7 +618,7 @@ def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519
message += signature
cipher_text = encode_base64(message)
cipher_text = encode_base64(bytes(message))
encrypted_payload = {
"algorithm" : "m.megolm.v1.aes-sha2",
@@ -653,7 +662,7 @@ def export_recovery_key(key_b64: str) -> str:
export_bytes += parity_byte.to_bytes(1, 'big')
# The byte string is encoded using base58
recovery_key = base58.b58encode(export_bytes).decode('utf-8')
recovery_key = base58.b58encode(bytes(export_bytes)).decode('utf-8')
split = [recovery_key[i:i + 4] for i in range(0, len(recovery_key), 4)]
return ' '.join(split)
+76 -51
View File
@@ -3,9 +3,9 @@
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import { type IDeviceKeys, type IMegolmSessionData } from "../../../src/@types/crypto";
import { type IDownloadKeyResult, type IEvent } from "../../../src";
import { type KeyBackupSession, type KeyBackupInfo } from "../../../src/crypto-api/keybackup";
import type { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
import type { IDownloadKeyResult, IEvent } from "../../../src";
import type { KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions } from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
@@ -118,26 +118,6 @@ export const ONE_TIME_KEYS = {
}
};
/** base64-encoded backup decryption (private) key */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
@@ -149,7 +129,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
},
{
"algorithm": "m.megolm.v1.aes-sha2",
@@ -160,7 +141,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "IrkbT6H+0urDf6wKDSyVC1fh1t84Vz6T62snni86Cog"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
}
];
@@ -174,7 +156,8 @@ export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
"sender_claimed_keys": {
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
};
/** A ratcheted version of MEGOLM_SESSION_DATA */
@@ -196,7 +179,7 @@ export const CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
"forwarded_count": 0,
"is_verified": false,
"session_data": {
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d1BPGxhDl8AhPcBu3//Kzlq2o5CssPsw+88gRehkAsPg9Zp5G9sL9to6giltvTWTbsaQpmvv3HLmBOYSFIxvyZrOT/Ffqu325f0IEsKcyV2BdIkw8Ob9Xt+VWoe4MYEGG6y1T8W125zeFgKWI4Ow76uput64H9zZjIo+Cc+hCTO9Ea4EnosSjizCotevkNck7C/zGgfhBikiohROb6SbaZgxicSsEDZ+f7brnri9yP3iXS3PMDHHpa1+XzG2VOG/Y9OQZpkPq+pbLrCC+NWJeJPslDAK5i+RURwzjnPmaHKCRHTq86CwhFyiCDf61MGwCY3xjrmBJg44BCdxWqCx0YJvwsvVqqnl4vTieUfrwThNPsQ81aVkDHvlmrgrTt8icDa8jTJhu34jem+pbRSEM5aJikV4B+zYiLz+dH/v6UpYA2eG8ReOvwpPXp6CAcIlplRPpWbMBeLFVcPkT4KAXTp9exFpB4on4pf8OsaDomlt4qAA0rhAZmhPWPKcU/A0Tz4gyMu54OivVtw1SPj+5Iq+YDQ8jB6Po3ApzMf6fwF9x/FjevbboFB05X2Jr0NrbFqXMOUwXHMgDAGiIWX8+gkmmbaiNWqg2etjN94pobQSGZelb18XGN7kuwMk+Zwk7A",
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d/r0NTff1SQt+1GopZkT0nq6jF5Wh/oX+8iwtYjHvTxMpN1UQoXAvRF40O+EVg+Q3efJXh1t45cMco8EWU64VerOir+k7cQ3C9FtcgQw3kmz3s3HeVY10o13X/w6+rc8n6vXqxuIxYHnFxanxX8B6TgTMZNajNfVsmJV0aC1aezim7E2gsftc+6+zW5G+rCFaEsWV/IuSOUz0+Hh0U+7hzSrz9/4qXPEVmPy1f6Ll4hhquPAlXPVDwddqlJDYj7kmvzr1g3bKVpk+TtKDbWlVQDPaJx2DEI2jGkPYjhYb7okpTFKpUny94dZmFIQqCeSGPIniaq8Y+/CanugQ1ZRVQcThuXrTewqWhXcpVvkVHT9i4ImcpBl95HzCBXuiwSUv6FKvO25fp++w555rbn2piFtilrUwnkrZPW32jFuaQcKZF4mZwcLeH7POL5UCuS4TWyaKyArp7bRzXwWuIq1wPET2nAMUmUVL7ge2+tAevk1WOIsjLgSaz/g55wO3Yma7yhXRFKcnzTjS0hUQOZ3GfTNwCM4pjzAtIPzvVd4Fp0b1emWZS5WyOYdXsceEDi3c6WtkoHWOKhPU0zBzn8hA9TdlFFqKzf2QFbN5Zgg0gprDLnLWgpc3/ieI4C7ndEQ7ZeTNMXbT/Y10APFk3qO+IGkLXJ97/qTF41EXFDhlsL0",
"ephemeral": "q+P1WdRtEiPIEtNuuGrRcueZxUbLnSKdsuTAkxewXgU",
"mac": "OibmACbORhI"
}
@@ -229,6 +212,37 @@ export const ENCRYPTED_EVENT: Partial<IEvent> = {
"origin_server_ts": 1507753886000
};
/** base64-encoded backup decryption (private) key that matches the public key in CURVE25519_KEY_BACKUP_DATA */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** base64-encoded backup decryption (private) key that does not match the public key in CURVE25519_KEY_BACKUP_DATA */
export const BACKUP_DECRYPTION_KEY_BASE64_ALT = "dh4fP2LITyJusgnb0dEq/SQK253WGObvLxXF5FEX6qc";
/** Backup decryption key in export format */
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
* Contains the key from MEGOLM_SESSION_DATA.
*/
export const PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
[MEGOLM_SESSION_DATA.session_id]: CURVE25519_KEY_BACKUP_DATA
};
// Bob data
export const BOB_TEST_USER_ID = "@bob:xyz";
@@ -338,26 +352,6 @@ export const BOB_ONE_TIME_KEYS = {
}
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
@@ -369,7 +363,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
},
{
"algorithm": "m.megolm.v1.aes-sha2",
@@ -380,7 +375,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
}
];
@@ -394,7 +390,8 @@ export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
"sender_claimed_keys": {
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
};
/** A ratcheted version of BOB_MEGOLM_SESSION_DATA */
@@ -416,7 +413,7 @@ export const BOB_CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
"forwarded_count": 0,
"is_verified": false,
"session_data": {
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAMW+71BJqIPQW910vV7SX3IcGylnoUcS3doVkJZiprXytXMP89AKcgv5Dj7mS2ZdvNGE+Atro74bzZ5yot5BrE0ZE5SjoUBPLaLMMu9HopLIV+qx01Rc3F0wmkocSPo51N0nv6wvO5Cst0FiOGHDK6r1pFlgDEJLmBkOyC4e8oMVbKTJzsSQVbJ8tJ37xuhI+T5P0ZlmiqKDqYRp8uh50w+txLEixYhEUunFgCTt1DAmiS9pLNYhLyl1ggwuQjzZe+AV6timbRxNJy18/AEcPomJw7z/pxYIiNLHRKOC13Wp8kGWx9cOgfMQ5KmBuLS8psGiLTBkfWPLOfNYqjbeqAR+OGZQoS6hUjbBYU7QuFa4FOYBHkNB2UqNsdsMb9qB/qs7QGTSb8Lok5YjW1c81BUpmIyKvuqnKma0MZskrpTYGQD2eJDABFCZwLFm+LgDyUTeSiV5xguYztLrHOk8LHKo9M8dIZgoBjeFVJxyjbcXKsVS3aQkMXKCrRlKLqhZTws/ZJwVfW9DbktZ9dT+tRZQvI7tjJofojcLX61AGJDnqUf5+2Gv1tEnmUI953gIzc8NlcFabPOsDsZEODt7MdOCTPT3w29umyhKbCsslpb64LoS/AB2QRPRCgkJS7snRA",
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAA7TEpHlx8Ks23hCqXmVW710VjqK2K9xnWCyJvkHfE8x0w6AYvffDj+tRVP8C8M7t4849rD2itn0uma+YMkvjG/nANUTxG1dBf3oUOZ673vflCPoaz7s7x9ZNhYDVSVH5JTdMgNwwN42R5dqqxnGTu516tJzJh/9BWvyD9oIPWJ8X0rt1sbzEJ3PZeBXcSy8GTlZ1SgSFjeiXlwYxOZCaX2sxprk4N1oI1db6g+wCDBhbCGGucJIlTDJna/h9/C5J4drGd/fkisG3SidUmJXXCyInhs/BhwjGAtTGeQS8j7R8UnJxhMulYBHSckzj0Kas71LElPp8W8M4Jq81APA03n5UfYB+U6jbxjDgf8OJnxGQyrteq9F2+SEvS/TwHe1pE3t6EM2mDYRoYDTpU5pTNYSJkGIQMfWJKRxxuWUGs29o1twewJ6dhHgm+SlCII0M7ESoVdV54vxZCvHZnPcR0NXDzal7ils7zBKJmamHfPQBuaqNPU3KmSo+5R8ngFPaWU5LbWqYp/WxSBfNCoLZ7Jf8Io5uitjXTATR2qy2r6l/RJmk3RlfP51kliQqI2TWqRF96oaB96IGgUGSFCX/2pv0psOBGc1SjfmMB3d7gYis+2iBYVbG3xmnpeXbqvlD0Lw9TiTIPkjhJkTW1+lXyhy1xVH9ZmcFamcL7bX15Jx",
"ephemeral": "oO0VX84OUIzm2i/12zAhTWOZT5IFRH5mXaKZ8fXkCgU",
"mac": "lEfHlqfJQwU"
}
@@ -449,6 +446,34 @@ export const BOB_ENCRYPTED_EVENT: Partial<IEvent> = {
"origin_server_ts": 1507753886000
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
* Contains the key from BOB_MEGOLM_SESSION_DATA.
*/
export const BOB_PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
[BOB_MEGOLM_SESSION_DATA.session_id]: BOB_CURVE25519_KEY_BACKUP_DATA
};
/** A second set of signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const BOB_ALT_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
"master_keys": {
+18 -4
View File
@@ -13,6 +13,7 @@ import {
import {
ClientEvent,
EventType,
HistoryVisibility,
type IJoinedRoom,
type IPusher,
type ISyncResponse,
@@ -57,14 +58,19 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
}
/**
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
* @param roomMembers
* @param roomId
* Return a sync response which contains a single room (by default `TEST_ROOM_ID`), with the members given
* and history visibility set to `shared`.
*
* @returns the sync response
* @param roomMembers - An array of user IDs representing the members of the room.
* @param roomHistoryVisibility - The history visibility setting for the room. Defaults to `shared`.
* @param roomId - The ID of the room. Defaults to `TEST_ROOM_ID`.
* @param encryptStateEvents - A boolean indicating whether state events should be encrypted. Defaults to `false`.
*
* @returns The sync response object containing the room data.
*/
export function getSyncResponse(
roomMembers: string[],
roomHistoryVisibility: HistoryVisibility = HistoryVisibility.Shared,
roomId = TEST_ROOM_ID,
encryptStateEvents = false,
): ISyncResponse {
@@ -85,6 +91,14 @@ export function getSyncResponse(
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
},
}),
mkEventCustom({
sender: roomMembers[0],
type: "m.room.history_visibility",
state_key: "",
content: {
history_visibility: roomHistoryVisibility,
},
}),
],
},
timeline: {
+1 -1
View File
@@ -32,7 +32,7 @@ describe("NamespacedValue", () => {
});
it("should have a falsey unstable if needed", () => {
const ns = new NamespacedValue("stable");
const ns = new NamespacedValue("stable", null);
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBeFalsy();
expect(ns.names).toEqual([ns.stable]);
+171 -1
View File
@@ -32,7 +32,14 @@ import {
type IRoomEvent,
} from "matrix-widget-api";
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
import {
createRoomWidgetClient,
EventType,
type IEvent,
MatrixError,
MsgType,
UpdateDelayedEventAction,
} from "../../src/matrix";
import { MatrixClient, ClientEvent, type ITurnServer as IClientTurnServer } from "../../src/client";
import { SyncState } from "../../src/sync";
import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
@@ -42,6 +49,7 @@ import { sleep } from "../../src/utils";
import { SlidingSync } from "../../src/sliding-sync";
import { logger } from "../../src/logger";
import { flushPromises } from "../test-utils/flushPromises";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../../src/models/room-sticky-events";
const testOIDCToken = {
access_token: "12345678",
@@ -49,6 +57,7 @@ const testOIDCToken = {
matrix_server_name: "homeserver.oabc",
token_type: "Bearer",
};
class MockWidgetApi extends EventEmitter {
public start = vi.fn().mockResolvedValue(undefined);
public getClientVersions = vi.fn();
@@ -167,6 +176,9 @@ describe("RoomWidgetClient", () => {
"org.matrix.rageshake_request",
{ request_id: 123 },
"!1:example.org",
undefined,
undefined,
undefined,
);
});
@@ -422,6 +434,7 @@ describe("RoomWidgetClient", () => {
"!1:example.org",
2000,
undefined,
undefined,
);
});
@@ -442,6 +455,7 @@ describe("RoomWidgetClient", () => {
"!1:example.org",
undefined,
parentDelayId,
undefined,
);
});
@@ -855,6 +869,162 @@ describe("RoomWidgetClient", () => {
});
});
describe("sticky events", () => {
describe("when supported", () => {
const doesServerSupportUnstableFeatureMock = vi.fn((feature) =>
Promise.resolve(feature === "org.matrix.msc4354"),
);
beforeAll(() => {
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
});
afterAll(() => {
doesServerSupportUnstableFeatureMock.mockReset();
});
it("requests capabilities when set", async () => {
await makeClient({ sendSticky: true, receiveSticky: true });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407ReceiveStickyEvent);
});
it("does not request capabilities when unset", async () => {
await makeClient({});
expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(
MatrixCapabilities.MSC4407ReceiveStickyEvent,
);
});
it("sends", async () => {
await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith(EventType.RTCMembership);
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
await client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, {
msc4354_sticky_key: "test",
});
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
EventType.RTCMembership,
{ msc4354_sticky_key: "test" },
"!1:example.org",
undefined,
undefined,
2000,
);
});
it("receives (adds, updates, then removes when redacted)", async () => {
await makeClient({ receiveEvent: [EventType.RTCMembership, EventType.RoomRedaction] });
const room = client.getRoom("!1:example.org")!;
function expectStickyEvents(events: IEvent[]) {
expect([...room._unstable_getStickyEvents()].map((e) => e.getEffectiveEvent())).toEqual(events);
}
async function sendAndExpectStickyUpdate(
eventToSend: IEvent,
added: IEvent[],
updated: { current: IEvent; previous: IEvent }[],
removed: IEvent[],
) {
const emittedStickyUpdate = new Promise<
Parameters<RoomStickyEventsMap[RoomStickyEventsEvent.Update]>
>((resolve) => room.once(RoomStickyEventsEvent.Update, (...args) => resolve(args)));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
detail: { data: eventToSend },
}),
);
const [addedReceived, updatedReceived, removedReceived] = await emittedStickyUpdate;
expect(addedReceived.map((e) => e.getEffectiveEvent())).toEqual(added);
expect(
updatedReceived.map(({ current, previous }) => ({
current: current.getEffectiveEvent(),
previous: previous.getEffectiveEvent(),
})),
).toEqual(updated);
expect(removedReceived.map((e) => e.getEffectiveEvent())).toEqual(removed);
}
// First, add a new sticky event to the map. The client should emit.
const event1 = new MatrixEvent({
type: EventType.RTCMembership,
event_id: "$pduhfiidph",
room_id: "!1:example.org",
sender: "@alice:example.org",
msc4354_sticky: { duration_ms: 1200000 },
content: { msc4354_sticky_key: "test" },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(event1, [event1], [], []);
// It should remain cached in the sticky map
expectStickyEvents([event1]);
// Next, update the same key in the sticky map
const event2 = new MatrixEvent({
type: EventType.RTCMembership,
event_id: "$zshgyutptfh",
room_id: "!1:example.org",
sender: "@alice:example.org",
msc4354_sticky: { duration_ms: 1200000 },
content: { msc4354_sticky_key: "test" },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(event2, [], [{ current: event2, previous: event1 }], []);
expectStickyEvents([event2]);
// Next, redact the second event. Because it has the first as a predecessor, the map should revert to
// the first event.
const redaction1 = new MatrixEvent({
type: EventType.RoomRedaction,
event_id: "$cimoexnvz",
room_id: "!1:example.org",
sender: "@alice:example.org",
redacts: event2.event_id,
content: { redacts: event2.event_id },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(redaction1, [], [{ current: event1, previous: event2 }], []);
expectStickyEvents([event1]);
// Finally, redact the first event. Now everything should be gone from the map.
const redaction2 = new MatrixEvent({
type: EventType.RoomRedaction,
event_id: "$drgzmenlh",
room_id: "!1:example.org",
sender: "@alice:example.org",
redacts: event1.event_id,
content: { redacts: event1.event_id },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(redaction2, [], [], [event1]);
expectStickyEvents([]);
});
});
describe("when unsupported", () => {
const doesServerSupportUnstableFeatureMock = vi.fn().mockResolvedValue(false);
beforeAll(() => {
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
});
afterAll(() => {
doesServerSupportUnstableFeatureMock.mockReset();
});
it("fails to send", async () => {
await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true });
await expect(
client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, {
msc4354_sticky_key: "test",
}),
).rejects.toThrow("Server does not support");
});
});
});
describe("to-device messages", () => {
const unencryptedContentMap = new Map([
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
+3
View File
@@ -51,18 +51,21 @@ describe("SSO login URL", function () {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, undefined);
const url = new URL(urlString);
expect(url.searchParams.has("org.matrix.msc3824.action")).toBe(false);
expect(url.searchParams.has("action")).toBe(false);
});
it("register", function () {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.REGISTER);
const url = new URL(urlString);
expect(url.searchParams.get("org.matrix.msc3824.action")).toEqual("register");
expect(url.searchParams.get("action")).toEqual("register");
});
it("login", function () {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.LOGIN);
const url = new URL(urlString);
expect(url.searchParams.get("org.matrix.msc3824.action")).toEqual("login");
expect(url.searchParams.get("action")).toEqual("login");
});
});
});
+80 -1
View File
@@ -88,7 +88,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";
import { type Transport } from "../../src/matrixrtc/index.ts";
vi.useFakeTimers();
@@ -3877,6 +3877,27 @@ describe("MatrixClient", function () {
makeClient();
});
it("should use stable prefix", async () => {
const metadata = mockOpenIdConfiguration();
client.getVersions = vi.fn().mockResolvedValue({
versions: ["v1.15"],
});
httpLookups = [
{
method: "GET",
path: `/auth_metadata`,
data: metadata,
prefix: "/_matrix/client/v1",
},
];
await expect(client.getAuthMetadata()).resolves.toEqual({
...metadata,
signingKeys: [],
});
expect(httpLookups.length).toEqual(0);
});
it("should use unstable prefix", async () => {
const metadata = mockOpenIdConfiguration();
httpLookups = [
@@ -3919,6 +3940,31 @@ describe("MatrixClient", function () {
});
expect(httpLookups.length).toEqual(0);
});
it("should handle no jwks_uri", async () => {
const { jwks_uri: _, ...metadata } = mockOpenIdConfiguration();
httpLookups = [
{
method: "GET",
path: `/auth_metadata`,
error: new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404),
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
{
method: "GET",
path: `/auth_issuer`,
data: { issuer: metadata.issuer },
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
];
fetchMock.get("https://auth.org/.well-known/openid-configuration", metadata);
await expect(client.getAuthMetadata()).resolves.toEqual({
...metadata,
signingKeys: null,
});
expect(httpLookups.length).toEqual(0);
});
});
describe("identityHashedLookup", () => {
@@ -3980,4 +4026,37 @@ describe("MatrixClient", function () {
]);
});
});
describe("getUrlPreview", () => {
it("makes a well-formed request to the new endpoint", async () => {
client.getVersions = vi.fn().mockResolvedValue({
versions: ["v1.11"],
});
httpLookups = [
{
method: "GET",
path: `/media/preview_url`,
expectQueryParams: { url: "https://example.org/", ts: "60000" },
data: { "og:title": "Test" },
prefix: "/_matrix/client/v1",
},
];
expect(await client.getUrlPreview("https://example.org", 60000)).toEqual({
"og:title": "Test",
});
});
it("makes a well-formed request to the old endpoint", async () => {
httpLookups = [
{
method: "GET",
path: `/preview_url`,
expectQueryParams: { url: "https://example.org/", ts: "60000" },
data: { "og:title": "Test" },
prefix: "/_matrix/media/v3",
},
];
expect(await client.getUrlPreview("https://example.org", 60000)).toEqual({
"og:title": "Test",
});
});
});
});
+85 -65
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,23 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IContent, type MatrixEvent } from "../../../src";
import {
CallMembership,
type SessionMembershipData,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks";
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
} as unknown as MatrixEvent;
}
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
import { type IContent, type MatrixEvent } from "../../../src/models/event.ts";
import { EventType } from "../../../src/@types/event.ts";
import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership.ts";
function createCallMembership(ev: MatrixEvent, content: IContent): CallMembership {
vi.mocked(ev.getContent).mockReturnValue(content);
@@ -40,6 +27,15 @@ function createCallMembership(ev: MatrixEvent, content: IContent): CallMembershi
describe("CallMembership", () => {
describe("SessionMembershipData", () => {
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
} as unknown as MatrixEvent;
}
beforeEach(() => {
vi.useFakeTimers();
});
@@ -158,8 +154,35 @@ describe("CallMembership", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
// slot_id is application and call_id dependent. So we create
// a membership for each possible combination
// non call application (should not alter call_id even with empty string)
const nonCallMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: "m.not.a.call",
call_id: "",
});
// non "" call id should not be altered
const callMembershipCustomId = createCallMembership(makeMockEvent(), {
...membershipTemplate,
call_id: "customCallId",
});
// for membership (application = m.call and call_id = "") we expect "" -> ROOM
// for legacy events we expect the room to be added automagically
// See INFO_SLOT_ID_LEGACY_CASE comments
expect(membership.slotId).toBe("m.call#ROOM");
expect(membership.slotDescription).toStrictEqual({ id: "ROOM", application: "m.call" });
expect(nonCallMembership.slotId).toBe("m.not.a.call#");
expect(nonCallMembership.slotDescription).toStrictEqual({ id: "", application: "m.not.a.call" });
expect(callMembershipCustomId.slotId).toBe("m.call#customCallId");
expect(callMembershipCustomId.slotDescription).toStrictEqual({
id: "customCallId",
application: "m.call",
});
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
@@ -185,9 +208,40 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toBe(true);
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
});
afterEach(() => {
vi.useFakeTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
});
describe("RtcMembershipData", () => {
function makeMockEvent(originTs = 0, content: IContent = {}): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue(content),
getType: vi.fn().mockReturnValue(EventType.RTCMembership),
} as unknown as MatrixEvent;
}
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
@@ -207,6 +261,11 @@ describe("CallMembership", () => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("rejects membership with slot_id that contains extra #", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#mycall#extra" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
@@ -307,13 +366,9 @@ describe("CallMembership", () => {
}).toThrow();
});
it.skip("considers memberships unexpired if local age low enough", () => {
// TODO link prev event
});
it.skip("considers memberships expired if local age large enough", () => {
// TODO link prev event
});
// TODO link prev event
it.todo("considers memberships unexpired if local age low enough");
it.todo("considers memberships expired if local age large enough");
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
@@ -370,44 +425,9 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toBe(false);
});
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
it("uses unpadded base64 for RTC backend identities", async () => {
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
expect(membership.rtcBackendIdentity).toBe("jUZ0Q1yF5nV3LlAI5xfD1I7BPnAytJaPEAR57EXjJ6s");
});
afterEach(() => {
vi.useRealTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
it("uses unpadded base64 for RTC backend identities", async () => {
expect(
await CallMembership.computeRtcBackendIdentity(makeMockEvent(), {
kind: "rtc",
data: {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzRANDOMxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
},
}),
).toBe("2+h2ELE1XY/NsuveToZOekORCoyQMO6V0W7XZUWk5Q4");
});
});
File diff suppressed because it is too large Load Diff
@@ -16,23 +16,50 @@ limitations under the License.
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 {
makeMockRoom,
type MembershipData,
sessionMembershipTemplate,
mockRoomState,
mockRTCEvent,
rtcMembershipTemplate,
} from "./mocks.ts";
import { logger } from "../../../src/logger";
import { flushPromises } from "../../test-utils/flushPromises";
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
"MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
function generateMembership(opts: { type: string; callId?: string } = { type: "m.call" }): MembershipData {
if (eventKind === "sticky") {
return {
...rtcMembershipTemplate,
slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id,
application: {
...rtcMembershipTemplate.application,
type: opts.type,
},
} satisfies RtcMembershipData & { user_id: string };
}
return {
...sessionMembershipTemplate,
application: opts.type,
call_id: opts.callId ?? sessionMembershipTemplate.call_id, // approximate version.
} satisfies SessionMembershipData & { user_id: string };
}
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
if (eventKind === "memberState") {
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
mockRoomState(room, [{ user_id: sessionMembershipTemplate.user_id }]);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
} else {
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
membershipData.splice(0, 1, { user_id: sessionMembershipTemplate.user_id });
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
await flushPromises();
@@ -46,22 +73,17 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
vi.resetAllMocks();
});
it("Fires event when session starts", async () => {
const onStarted = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
vi.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);
}
const room1 = makeMockRoom([generateMembership({ type: "m.call" })], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
const sessionStartedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
client.emit(ClientEvent.Room, room1);
await expect(sessionStartedPromise).resolves.toBeTruthy();
});
it("Doesn't fire event if unrelated sessions starts", () => {
@@ -69,7 +91,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
const room1 = makeMockRoom([generateMembership({ type: "m.other" })], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
@@ -80,17 +102,24 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
});
it("Fires event when session ends", async () => {
const onEnded = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
const sessionStartedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
const sessionEndedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
);
const membershipData: MembershipData[] = [generateMembership()];
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
vi.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
await flushPromises();
await sessionStartedPromise;
await sendLeaveMembership(room1, membershipData);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
await expect(sessionEndedPromise).resolves.toStrictEqual([
room1.roomId,
client.matrixRTC.getActiveRoomSession(room1),
]);
});
it("Fires correctly with custom sessionDescription", async () => {
@@ -106,48 +135,44 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
const sessionStartedPromise = new Promise((resolve) =>
sessionManager.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
const sessionEndedPromise = new Promise((resolve) =>
sessionManager.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
);
try {
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.other, we ignore this session because it lacks a call_id
const room1MembershipData: MembershipData[] = [generateMembership({ type: "m.other" })];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
const room2MembershipData: MembershipData[] = [
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has matching call_id
const room2MembershipData: MembershipData[] = [generateMembership({ type: "m.notCall", callId: "test" })];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
await sessionStartedPromise;
// Stop room1's RTC session. Tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// Stop room1's RTC session. Not tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
// Stop room1's RTC session. Not tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
// Stop room2's RTC session. Tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
await sessionEndedPromise;
});
it("Doesn't fire event if unrelated sessions ends", async () => {
const onEnded = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
const membership: MembershipData[] = [generateMembership({ type: "m.other_app" })];
const room1 = makeMockRoom(membership, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
vi.spyOn(client, "getRoom").mockReturnValue(room1);
@@ -0,0 +1,27 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { computeRtcIdentityRaw } from "../../../src/matrixrtc/membershipData/index.ts";
describe("computeRtcIdentityRaw", () => {
it("should compute the correct identity hash", async () => {
// Test vector taken from the spec, with the expected output updated to match the unpadded base64 encoding
// https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md#appendix-hash-derivation-test-vectors
const result = await computeRtcIdentityRaw("@alice:example.com", "DEVICE123", "memberABC");
// Add assertions based on expected hash output
expect(result).toBe("J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU");
});
});
+88 -21
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,15 +25,16 @@ import {
type Room,
MAX_STICKY_DURATION_MS,
} from "../../../src";
import { MembershipManagerEvent, Status, type Transport, type LivekitFocusSelection } from "../../../src/matrixrtc";
import {
MembershipManagerEvent,
Status,
type Transport,
type SessionMembershipData,
type LivekitFocusSelection,
} from "../../../src/matrixrtc";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
makeMockClient,
makeMockRoom,
sessionMembershipTemplate,
mockCallMembership,
type MockClient,
} from "./mocks.ts";
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
import { type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
/**
* Create a promise that will resolve once a mocked method is called.
@@ -71,7 +72,7 @@ function createAsyncHandle<T>(method: MockedFunction<(...args: any[]) => any>) {
return { reject, resolve };
}
const callSession = { id: "", application: "m.call" };
const callSession = { id: "ROOM", application: "m.call" };
describe("MembershipManager", () => {
let client: MockClient;
@@ -90,7 +91,7 @@ describe("MembershipManager", () => {
// Default to fake timers.
vi.useFakeTimers();
client = makeMockClient("@alice:example.org", "AAAAAAA");
room = makeMockRoom([membershipTemplate]);
room = makeMockRoom([sessionMembershipTemplate]);
// Provide a default mock that is like the default "non error" server behaviour.
vi.mocked(client._unstable_sendDelayedStateEvent).mockResolvedValue({ delay_id: "id" });
vi.mocked(client._unstable_updateDelayedEvent).mockResolvedValue({});
@@ -139,6 +140,7 @@ describe("MembershipManager", () => {
"org.matrix.msc3401.call.member",
{
application: "m.call",
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
call_id: "",
device_id: "AAAAAAA",
expires: 14400000,
@@ -147,6 +149,7 @@ describe("MembershipManager", () => {
focus_active: focusActive,
scope: "m.room",
},
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
"_@alice:example.org_AAAAAAA_m.call",
);
restartScheduledDelayedEventHandle.resolve?.();
@@ -160,6 +163,45 @@ describe("MembershipManager", () => {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("sends correct call_id and state key when using non empty string. Not using empty string -> ROOM hack. See: INFO_SLOT_ID_LEGACY_CASE", async () => {
// Spys/Mocks
const customCallSession = { id: "custom", application: "m.call" };
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent,
);
// Test
const memberManager = new MembershipManager(undefined, room, client, customCallSession);
memberManager.join([focus], undefined);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{
application: "m.call",
call_id: "custom",
device_id: "AAAAAAA",
expires: 14400000,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA_m.callcustom",
);
restartScheduledDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA_m.callcustom",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
@@ -359,7 +401,7 @@ describe("MembershipManager", () => {
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await vi.advanceTimersByTimeAsync(RESTART_DELAY);
// first simulate the sync, then resolve sending the delayed event.
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
// Let the scheduler run one iteration so that the new join gets sent
await vi.runOnlyPendingTimersAsync();
@@ -416,8 +458,9 @@ describe("MembershipManager", () => {
await manager.leave();
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
expect(client.sendStateEvent).toHaveBeenCalled();
expect(manager.delayId).toBe(undefined);
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
it("send leave event when leave is called and resolving delayed leave fails unknown error", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await vi.advanceTimersByTimeAsync(1);
@@ -431,6 +474,27 @@ describe("MembershipManager", () => {
{},
"_@alice:example.org_AAAAAAA_m.call",
);
// If there is a unknown error, we do not reset the delayId
// The delayed event might still be around and we track it.
expect(manager.delayId).not.toBe(undefined);
});
it("send leave event when leave is called and resolving delayed leave fails not found error", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await vi.advanceTimersByTimeAsync(1);
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND" }, 404),
);
await manager.leave();
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA_m.call",
);
expect(manager.delayId).toBe(undefined);
});
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, callSession);
@@ -443,7 +507,7 @@ describe("MembershipManager", () => {
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
await vi.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
@@ -466,7 +530,7 @@ describe("MembershipManager", () => {
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
await manager.onRTCSessionMemberUpdate([
mockCallMembership(membershipTemplate, room.roomId),
mockCallMembership(sessionMembershipTemplate, room.roomId),
mockCallMembership(
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
room.roomId,
@@ -492,7 +556,7 @@ describe("MembershipManager", () => {
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
// Our own membership is removed:
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
await vi.advanceTimersByTimeAsync(1);
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
@@ -515,7 +579,7 @@ describe("MembershipManager", () => {
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await vi.advanceTimersByTimeAsync(10_000);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
await vi.advanceTimersByTimeAsync(10_000);
@@ -887,7 +951,10 @@ describe("MembershipManager", () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
const membership = mockCallMembership(
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
@@ -901,7 +968,7 @@ describe("MembershipManager", () => {
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
{ ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
@@ -947,8 +1014,8 @@ describe("MembershipManager", () => {
id: "@alice:example.org:AAAAAAA_m.call",
device_id: "AAAAAAA",
},
slot_id: "m.call#",
rtc_transports: [focus],
slot_id: "m.call#ROOM",
rtc_transports: [{ type: focus.type, livekit_service_url: focus.livekit_service_url }],
versions: [],
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
@@ -976,7 +1043,7 @@ describe("MembershipManager", () => {
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]);
const room = makeMockRoom([sessionMembershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, callSession);
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,10 +17,10 @@ limitations under the License.
import { type Mock, type Mocked } from "vitest";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
import { type CallMembership } from "../../../src/matrixrtc";
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { logger } from "../../../src/logger.ts";
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
@@ -31,20 +31,10 @@ describe("RTCEncryptionManager", () => {
let encryptionManager: RTCEncryptionManager;
let getMembershipMock: Mock;
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: Mock;
let rtcIdentifierProvider: Mock;
beforeEach(() => {
statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
getMembershipMock = vi.fn().mockReturnValue([]);
onEncryptionKeysChanged = vi.fn();
mockTransport = {
@@ -63,7 +53,6 @@ describe("RTCEncryptionManager", () => {
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
@@ -223,8 +212,6 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
// Test an edge case where the use key delay is higher than the grace period.
@@ -322,8 +309,6 @@ describe("RTCEncryptionManager", () => {
await vi.advanceTimersByTimeAsync(5000);
expect(onEncryptionKeysChanged).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not rotate key when several users join within the rotation grace period", async () => {
@@ -464,8 +449,6 @@ describe("RTCEncryptionManager", () => {
},
"@alice:example.org:DEVICE01",
);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not distribute keys if encryption is disabled", async () => {
@@ -501,7 +484,6 @@ describe("RTCEncryptionManager", () => {
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
@@ -525,7 +507,6 @@ describe("RTCEncryptionManager", () => {
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
});
it("should accept keys from transport", async () => {
@@ -597,8 +578,6 @@ describe("RTCEncryptionManager", () => {
},
"rtcIDCARL1",
);
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
});
it("Should support quick re-joiner if keys received out of order", async () => {
@@ -914,7 +893,6 @@ describe("RTCEncryptionManager", () => {
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
@@ -983,7 +961,7 @@ describe("RTCEncryptionManager", () => {
rtcBackendIdentity: string,
): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
rtcBackendIdentity,
);
@@ -998,7 +976,7 @@ describe("RTCEncryptionManager", () => {
*/
function aStateBaseMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
`${userId}|${deviceId}`,
);
@@ -1,228 +0,0 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked } from "vitest";
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
import { EventType, MatrixClient, RoomEvent } from "../../../src";
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
import type { Logger } from "../../../src/logger.ts";
describe("RoomKeyTransport", () => {
let client: MatrixClient;
let room: Room & {
emitTimelineEvent: (event: MatrixEvent) => void;
};
let transport: RoomKeyTransport;
let mockLogger: Mocked<Logger>;
const onCallEncryptionMock = vi.fn();
beforeEach(() => {
onCallEncryptionMock.mockReset();
mockLogger = {
debug: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
} as unknown as Mocked<Logger>;
const statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
room = makeMockRoom([membershipTemplate]);
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
transport = new RoomKeyTransport(room, client, statistics, {
getChild: vi.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
onCallEncryptionMock(...p);
});
transport.start();
});
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
transport.stop();
});
it("Calls onCallEncryption on encryption keys event", async () => {
client.decryptEventIfNeeded = () => Promise.resolve();
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
});
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
await new Promise(process.nextTick);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
describe("event decryption", () => {
it("Retries decryption and processes success", async () => {
vi.useFakeTimers();
let isDecryptionFailure = true;
client.decryptEventIfNeeded = vi
.fn()
.mockReturnValueOnce(Promise.resolve())
.mockImplementation(() => {
isDecryptionFailure = false;
return Promise.resolve();
});
const timelineEvent = Object.assign(
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
{ isDecryptionFailure: vi.fn().mockImplementation(() => isDecryptionFailure) },
);
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await vi.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("Retries decryption and processes failure", async () => {
try {
vi.useFakeTimers();
const onCallEncryptionMock = vi.fn();
client.decryptEventIfNeeded = vi.fn().mockReturnValue(Promise.resolve());
const timelineEvent = Object.assign(
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
{ isDecryptionFailure: vi.fn().mockReturnValue(true) },
);
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await vi.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// doesn't retry again:
await vi.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
} finally {
vi.useRealTimers();
}
});
});
describe("malformed events", () => {
const MALFORMED_EVENT = [
// empty content
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {},
}),
// no sender
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
content: {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
// Call_id not empty string
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "FOO",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
// Various Malformed keys
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "",
keys: "FOO",
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "",
keys: [{ index: 0 }],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "",
keys: [
{
key: "BASE64KEY",
index: "mcall",
},
],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
];
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
transport.onEncryptionEvent(event);
expect(mockLogger.warn).toHaveBeenCalled();
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
});
});
});
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "vitest";
import { makeMockEvent } from "./mocks";
import { makeMockEvent } from "./mocks.ts";
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
+40 -7
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -18,12 +18,12 @@ import { EventEmitter } from "stream";
import { type Mocked, type MockedObject } from "vitest";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
import { CallMembership } from "../../../src/matrixrtc";
import { secureRandomString } from "../../../src/randomstring";
import { type CallMembershipIdentityParts } from "src/matrixrtc/EncryptionManager";
import { logger } from "../../../src/logger.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager";
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string };
export const owmMemberIdentity: CallMembershipIdentityParts = {
deviceId: "AAAAAAA",
@@ -31,7 +31,7 @@ export const owmMemberIdentity: CallMembershipIdentityParts = {
userId: "@alice:example.org",
};
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = {
application: "m.call",
call_id: "",
user_id: "@mock:user.example",
@@ -52,6 +52,39 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = {
],
};
export const rtcMembershipTemplate: RtcMembershipData & { user_id: string } = {
user_id: "@mock:user.example",
application: {
type: "m.call",
},
member: {
id: "IDIDID",
user_id: "@mock:user.example",
device_id: "AAAAAAA",
},
slot_id: "m.call#ROOM",
versions: [],
rtc_transports: [
{
type: "livekit",
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: [
{
livekit_alias: "!alias:something.org",
livekit_service_url: "https://livekit-jwt.something.io",
type: "livekit",
},
{
livekit_alias: "!alias:something.org",
livekit_service_url: "https://livekit-jwt.something.dev",
type: "livekit",
},
],
},
],
msc4354_sticky_key: "m.call#",
};
export type MockClient = MockedObject<
Pick<
MatrixClient,
@@ -198,7 +231,7 @@ export function mockCallMembership(
const ev = mockRTCEvent(membershipData, roomId);
vi.mocked(ev.getContent).mockReturnValue(membershipData);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx");
}
export function makeKey(id: number, key: string): { key: string; index: number } {
+44 -1
View File
@@ -175,12 +175,29 @@ describe("oidc authorization", () => {
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234");
});
it("should generate url with response_mode=fragment", async () => {
const nonce = "abc123";
const authUrl = new URL(
await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig,
homeserverUrl: baseUrl,
clientId,
redirectUri: baseUrl,
nonce,
responseMode: "fragment",
}),
);
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
});
});
describe("completeAuthorizationCodeGrant", () => {
const homeserverUrl = "https://server.org/";
const identityServerUrl = "https://id.org/";
const nonce = "test-nonce";
const nonce = "hRpB6pkE06";
const redirectUri = baseUrl;
const code = "auth_code_xyz";
const validBearerTokenResponse = {
@@ -290,6 +307,32 @@ describe("oidc authorization", () => {
expect(queryParams.get("code")).toEqual(code);
});
it("should make correct request to the token endpoint with response_mode=fragment", async () => {
const state = await setupState({ responseMode: "fragment" });
const codeVerifier = getValueFromStorage(state, "code_verifier");
await completeAuthorizationCodeGrant(code, state, "fragment");
expect(fetchMock.callHistory.lastCall(metadata.token_endpoint)?.options).toStrictEqual(
expect.objectContaining({
method: "post",
credentials: "same-origin",
headers: {
"accept": "application/json",
"content-type": "application/x-www-form-urlencoded",
},
}),
);
// check body is correctly formed
const queryParams = fetchMock.callHistory.lastCall(metadata.token_endpoint)!.options
.body as URLSearchParams;
expect(queryParams.get("grant_type")).toEqual("authorization_code");
expect(queryParams.get("client_id")).toEqual(clientId);
expect(queryParams.get("code_verifier")).toEqual(codeVerifier);
expect(queryParams.get("redirect_uri")).toEqual(redirectUri);
expect(queryParams.get("code")).toEqual(code);
});
it("should return with valid bearer token", async () => {
const state = await setupState();
const scope = getValueFromStorage(state, "scope");
+102
View File
@@ -275,6 +275,108 @@ describe("Relations", function () {
expect(badlyEditedTopic.getContent().topic).toBe("topic");
});
describe("m.replace async ordering", () => {
const userId = "@bob:example.com";
const roomId = "!room:example.com";
const targetEventId = "$target";
function makeEditEvent(eventId: string, ts: number): MatrixEvent {
return new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: eventId,
room_id: roomId,
origin_server_ts: ts,
content: {
"body": `edited ${eventId}`,
"msgtype": "m.text",
"m.new_content": {
body: `edited ${eventId}`,
msgtype: "m.text",
},
"m.relates_to": {
event_id: targetEventId,
rel_type: "m.replace",
},
},
});
}
it("should not let a slow-decrypting older edit overwrite a newer one", async () => {
const room = new Room(roomId, new TestClient(userId).client, userId);
const relations = new Relations("m.replace", "m.room.message", room);
const targetEvent = new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: targetEventId,
room_id: roomId,
origin_server_ts: 1000,
content: { body: "original", msgtype: "m.text" },
});
await relations.setTargetEvent(targetEvent);
// Create two edits: edit1 is older (ts=2000), edit2 is newer (ts=3000).
const edit1 = makeEditEvent("$edit1", 2000);
const edit2 = makeEditEvent("$edit2", 3000);
// Simulate edit1 being in the process of decryption: isBeingDecrypted()
// returns true and getDecryptionPromise() returns a deferred promise.
let resolveEdit1Decryption!: () => void;
const edit1DecryptionPromise = new Promise<void>((resolve) => {
resolveEdit1Decryption = resolve;
});
vi.spyOn(edit1, "isBeingDecrypted").mockReturnValue(true);
vi.spyOn(edit1, "getDecryptionPromise").mockReturnValue(edit1DecryptionPromise);
vi.spyOn(edit1, "shouldAttemptDecryption").mockReturnValue(false);
// edit2 is already decrypted.
vi.spyOn(edit2, "isBeingDecrypted").mockReturnValue(false);
vi.spyOn(edit2, "shouldAttemptDecryption").mockReturnValue(false);
// Add edit1 first (it will block on decryption).
const addEdit1Promise = relations.addEvent(edit1);
// While edit1 is still decrypting, add edit2 (resolves immediately).
await relations.addEvent(edit2);
// edit2 should be applied as the replacement (it's newer).
expect(targetEvent.replacingEvent()).toBe(edit2);
// Now resolve edit1's decryption — the stale result must NOT overwrite edit2.
resolveEdit1Decryption();
await addEdit1Promise;
// edit2 must still be the replacing event, not edit1.
expect(targetEvent.replacingEvent()).toBe(edit2);
});
it("should apply an edit correctly when there is no concurrency", async () => {
const room = new Room(roomId, new TestClient(userId).client, userId);
const relations = new Relations("m.replace", "m.room.message", room);
const targetEvent = new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: targetEventId,
room_id: roomId,
origin_server_ts: 1000,
content: { body: "original", msgtype: "m.text" },
});
await relations.setTargetEvent(targetEvent);
const edit = makeEditEvent("$edit1", 2000);
vi.spyOn(edit, "isBeingDecrypted").mockReturnValue(false);
vi.spyOn(edit, "shouldAttemptDecryption").mockReturnValue(false);
await relations.addEvent(edit);
expect(targetEvent.replacingEvent()).toBe(edit);
});
});
it("getSortedAnnotationsByKey should return null for non-annotation relations", async () => {
const userId = "@user:server";
const room = new Room("room123", new TestClient(userId).client, userId);
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type EstablishedEcies, QrCodeData, QrCodeIntent, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
@@ -28,7 +28,7 @@ describe("MSC4108SecureChannel", () => {
});
const channel = new MSC4108SecureChannel(session);
const code = await channel.generateCode(QrCodeMode.Login);
const code = await channel.generateCode(QrCodeIntent.Login);
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
@@ -43,7 +43,7 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
@@ -64,7 +64,7 @@ describe("MSC4108SecureChannel", () => {
vi.mocked(mockSession.receive).mockResolvedValue("");
await expect(channel.connect()).rejects.toThrow("No response from other device");
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
@@ -87,7 +87,7 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
+140
View File
@@ -1308,4 +1308,144 @@ describe("RoomState", function () {
).toBeFalsy();
});
});
describe("reactive display name disambiguation", function () {
it("should disambiguate existing member when another member changes to the same name", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Alice joins with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
// Bob joins with display name "Bob"
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Bob",
});
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
// Verify no disambiguation needed initially
const aliceBefore = testState.getMember(userA);
const bobBefore = testState.getMember(userB);
expect(aliceBefore?.disambiguate).toBe(false);
expect(bobBefore?.disambiguate).toBe(false);
expect(aliceBefore?.name).toBe("Alice");
expect(bobBefore?.name).toBe("Bob");
// Bob changes display name to "Alice"
const bobRenameEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([bobRenameEvent]);
// Now both should be disambiguated
const aliceAfter = testState.getMember(userA);
const bobAfter = testState.getMember(userB);
expect(aliceAfter?.disambiguate).toBe(true);
expect(bobAfter?.disambiguate).toBe(true);
expect(aliceAfter?.name).toContain(userA);
expect(bobAfter?.name).toContain(userB);
});
it("should un-disambiguate member when conflicting member changes to different name", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Both Alice and Bob join with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
// Verify both are disambiguated
const aliceBefore = testState.getMember(userA);
const bobBefore = testState.getMember(userB);
expect(aliceBefore?.disambiguate).toBe(true);
expect(bobBefore?.disambiguate).toBe(true);
// Bob changes display name to "Bob"
const bobRenameEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Bob",
});
testState.setStateEvents([bobRenameEvent]);
// Alice should no longer be disambiguated, Bob should not be either
const aliceAfter = testState.getMember(userA);
const bobAfter = testState.getMember(userB);
expect(aliceAfter?.disambiguate).toBe(false);
expect(bobAfter?.disambiguate).toBe(false);
expect(aliceAfter?.name).toBe("Alice");
expect(bobAfter?.name).toBe("Bob");
});
it("should emit RoomState.members for affected members when disambiguation changes", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Alice joins with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([aliceJoinEvent]);
// Set up listener for Members event
const membersEmitted: string[] = [];
testState.on(RoomStateEvent.Members, (_ev, _state, member) => {
membersEmitted.push(member.userId);
});
// Bob joins with display name "Alice" - should trigger disambiguation for Alice
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([bobJoinEvent]);
// Both Alice and Bob should have emitted Members events
expect(membersEmitted).toContain(userA);
expect(membersEmitted).toContain(userB);
});
});
});
+57
View File
@@ -0,0 +1,57 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "../../src/client";
describe("Room upgrades", function () {
it("Sends an HTTP request upgrading the room", () => {
// Given a client with a fake authedRequest method
const { client, authedRequest } = createClient();
// When we upgrade the room to version 12
client.upgradeRoom("!r1", "12");
// Then we make an HTTP request to the correct endpoint, with the
// version provided in the JSON.
expect(authedRequest).toHaveBeenCalledWith("POST", "/rooms/!r1/upgrade", undefined, { new_version: "12" });
});
it("Includes additional_creators if provided", () => {
// Given a client with a fake authedRequest method
const { client, authedRequest } = createClient();
// When we upgrade the room to version 13 and supply additionalCreators
client.upgradeRoom("!r1", "13", ["@u:s.co", "@v:a.b"]);
// Then we make an HTTP request to the correct endpoint, with the
// version and additional creators provided.
expect(authedRequest).toHaveBeenCalledWith("POST", "/rooms/!r1/upgrade", undefined, {
new_version: "13",
additional_creators: ["@u:s.co", "@v:a.b"],
});
});
});
///
function createClient(): { client: MatrixClient; authedRequest: any } {
const authedRequest = vi.fn();
const client = new MatrixClient({
baseUrl: "https://my.home.server",
userId: "@u:s.co",
});
client.http.authedRequest = authedRequest;
return { client, authedRequest };
}
@@ -236,4 +236,105 @@ describe("OutgoingRequestsManager", () => {
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(1);
});
});
describe("Repeated processing of outgoing requests", () => {
it("Processes any requests still in the queue after processing", async () => {
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
const [request1, request2, request3] = setupOutgoingTwoRequestsThenOne();
// (And requests finish immediately)
processor.makeOutgoingRequest.mockImplementation(async () => {
return;
});
// When we ask a manager to process the requests
await manager.doProcessOutgoingRequests();
// Then all three are processed because we re-call outgoingRequests
// to check for more
await vi.waitFor(() => expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3));
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request2);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request3);
});
it("Does reprocess if any request succeeded, even if some failed", async () => {
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
const [request1, request2, request3] = setupOutgoingTwoRequestsThenOne();
// And the first request fails, but subsequent ones pass
processor.makeOutgoingRequest
.mockImplementationOnce(async () => {
throw new Error("This request failed!");
})
.mockImplementation(async () => {
return;
});
// When we ask a manager to process the requests
await manager.doProcessOutgoingRequests();
// Then all three are processed because we re-call outgoingRequests
// to check for more, even though one failed
await vi.waitFor(() => expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3));
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request2);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request3);
});
it("Does not reprocess if all requests failed", async () => {
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
const [request1, request2, request3] = setupOutgoingTwoRequestsThenOne();
// And the first two requests fail, but subsequent ones pass
processor.makeOutgoingRequest
.mockImplementationOnce(async () => {
throw new Error("Request 1 failed!");
})
.mockImplementationOnce(async () => {
throw new Error("Request 2 failed!");
})
.mockImplementation(async () => {
return;
});
// When we ask a manager to process the requests
await manager.doProcessOutgoingRequests();
// Then only the first two requests are processed, because since
// they both failed we stop retrying
await vi.waitFor(() => expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1));
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(2);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request2);
expect(processor.makeOutgoingRequest).not.toHaveBeenCalledWith(request3);
});
/// Mock calls to olmMachine.outgoingRequests. The first time it is
// called, return two requests and the second time return a third one.
//
// Returns the three returned requests.
function setupOutgoingTwoRequestsThenOne(): [OutgoingRequest, OutgoingRequest, OutgoingRequest] {
const request1 = new RustSdkCryptoJs.KeysQueryRequest("1", "{}");
const request2 = new RustSdkCryptoJs.KeysUploadRequest("2", "{}");
const request3 = new RustSdkCryptoJs.KeysUploadRequest("3", "{}");
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
olmMachine.outgoingRequests
.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
return [request1, request2];
})
.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
return [request3];
});
return [request1, request2, request3];
}
});
});
@@ -4,7 +4,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"org.matrix.msc3061.shared_history": true,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
@@ -19,7 +19,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"org.matrix.msc3061.shared_history": true,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
+79 -1
View File
@@ -21,6 +21,7 @@ import {
KeysQueryRequest,
Migration,
OlmMachine,
type OtherUserIdentity,
type PickledInboundGroupSession,
type PickledSession,
StoreHandle,
@@ -109,6 +110,7 @@ describe("initRustCrypto", () => {
getBackupKeys: vi.fn(),
getIdentity: vi.fn().mockResolvedValue(null),
trackedUsers: vi.fn(),
getAllRoomsPendingKeyBundles: vi.fn().mockResolvedValue([]),
} as unknown as Mocked<OlmMachine>;
}
@@ -590,6 +592,72 @@ describe("RustCrypto", () => {
expect(res.length).toEqual(0);
});
it.each(["m.room_key_bundle", "io.element.msc4268.room_key_bundle"])(
"should accept key bundles when we find out about them",
async (type: string) => {
// Given we are faking that the received to-device message is a
// decrypted room key bundle.
// @ts-ignore Overriding a private function
rustCrypto.receiveSyncChanges = vi.fn().mockReturnValue([keyBundleEvent(type)]);
// And that there is a pending key bundle
// @ts-ignore Overriding a private function
rustCrypto.olmMachine.getPendingKeyBundleDetailsForRoom = vi.fn().mockReturnValue({
inviteAcceptedAtMillis: Date.now(),
inviterId: { toString: vi.fn().mockReturnValue("@inv:s.co") },
});
// When we process to-device messages
rustCrypto.maybeAcceptKeyBundle = vi.fn().mockName("maybeAcceptKeyBundle").mockResolvedValue(null);
await rustCrypto.preprocessToDeviceMessages([]);
// Then we accepted the key bundle
expect(rustCrypto.maybeAcceptKeyBundle).toHaveBeenCalledWith("!r:s.co", "@inv:s.co");
},
);
it("should not accept other to-device messages as key bundles when we receive them", async () => {
// Given we are faking that the received to-device message looks
// like a room key bundle, except it has the wrong type.
// @ts-ignore Overriding a private function
rustCrypto.receiveSyncChanges = vi.fn().mockReturnValue([keyBundleEvent("foo.some_other_type")]);
// And that there is a pending key bundle
// @ts-ignore Overriding a private function
rustCrypto.olmMachine.getPendingKeyBundleDetailsForRoom = vi.fn().mockReturnValue({
inviteAcceptedAtMillis: Date.now(),
inviterId: { toString: vi.fn().mockReturnValue("@inv:s.co") },
});
// When we process to-device messages
rustCrypto.maybeAcceptKeyBundle = vi.fn().mockName("maybeAcceptKeyBundle").mockResolvedValue(null);
await rustCrypto.preprocessToDeviceMessages([]);
// Then we do not try to accepted a key bundle
expect(rustCrypto.maybeAcceptKeyBundle).not.toHaveBeenCalledWith();
});
function keyBundleEvent(type: string): RustSdkCryptoJs.ProcessedToDeviceEvent {
return {
rawEvent: JSON.stringify({
content: { room_id: "!r:s.co" },
sender: "",
type,
}),
type: 0,
encryptionInfo: {
sender: "",
senderDevice: null,
senderCurve25519Key: "",
isSenderVerified: vi.fn().mockReturnValue(true),
},
} as any as RustSdkCryptoJs.ProcessedToDeviceEvent;
}
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
@@ -788,6 +856,7 @@ describe("RustCrypto", () => {
undefined,
secretStorage,
);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() {
return {
@@ -835,6 +904,7 @@ describe("RustCrypto", () => {
{} as CryptoCallbacks,
false,
);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() {
return {
@@ -1524,6 +1594,7 @@ describe("RustCrypto", () => {
it("returns an unverified UserVerificationStatus when there is no UserIdentity", async () => {
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.known).toBe(false);
expect(userVerificationStatus.isVerified()).toBeFalsy();
expect(userVerificationStatus.isTofu()).toBeFalsy();
expect(userVerificationStatus.isCrossSigningVerified()).toBeFalsy();
@@ -1535,9 +1606,10 @@ describe("RustCrypto", () => {
free: vi.fn(),
isVerified: vi.fn().mockReturnValue(true),
wasPreviouslyVerified: vi.fn().mockReturnValue(true),
});
} as unknown as OtherUserIdentity);
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.known).toBe(true);
expect(userVerificationStatus.isVerified()).toBeTruthy();
expect(userVerificationStatus.isTofu()).toBeFalsy();
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
@@ -2320,6 +2392,7 @@ describe("RustCrypto", () => {
});
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
// We have a key backup
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
@@ -2388,6 +2461,7 @@ describe("RustCrypto", () => {
queryKeysForUsers: vi.fn().mockReturnValue({}),
getReceivedRoomKeyBundleData: vi.fn(),
receiveRoomKeyBundle: vi.fn(),
clearRoomPendingKeyBundle: vi.fn(),
} as unknown as Mocked<OlmMachine>;
const http = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
@@ -2448,6 +2522,10 @@ describe("RustCrypto", () => {
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
// It should also flag the room as not waiting for a key bundle
expect(mockOlmMachine.clearRoomPendingKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.clearRoomPendingKeyBundle.mock.calls[0][0].toString()).toEqual("!room_id");
});
});
+10 -4
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OtherUserIdentity, type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type Mocked } from "vitest";
import {
@@ -138,7 +138,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
@@ -276,7 +278,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
@@ -394,7 +398,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [
+35
View File
@@ -79,6 +79,41 @@ describe("IndexedDBStore", () => {
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
});
it("should handle failed queries", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
// Simulate a failed query
let txn: IDBRequest;
(store.backend as LocalIndexedDBStoreBackend)["db"]!.transaction = (): IDBTransaction => {
return {
objectStore: (name: string) =>
({
name,
openCursor: (query: unknown) => {
return (txn = {
error: new DOMException("Expected error"),
} as IDBRequest);
},
}) as IDBObjectStore,
} as IDBTransaction;
};
// Call backend directly as otherwise the error is masked.
const promise = store.backend.getClientOptions();
// The function uses a Promise.then(() => trick to delay execution
// so we need to wait before we can call the txn onerror handler.
process.nextTick(() => {
txn!.onerror!(new Event("we-ignore-this"));
});
await expect(() => promise).rejects.toThrow("selectQuery failed for client_options");
});
it("Should load presence events on startup", async () => {
// 1. Create idb database
const indexedDB = new IDBFactory();
+14 -14
View File
@@ -314,7 +314,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
remote_stream: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: true,
@@ -420,7 +420,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -451,7 +451,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -478,7 +478,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -504,7 +504,7 @@ describe("Call", function () {
call.onSDPStreamMetadataChangedReceived(
makeMockEvent("@test:foo", {
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
remote_stream: {
purpose: SDPStreamMetadataPurpose.Screenshare,
audio_muted: true,
@@ -849,7 +849,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
[STREAM_ID]: {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
@@ -959,8 +959,8 @@ describe("Call", function () {
describe("sending sdp_stream_metadata_changed events", () => {
it("should send sdp_stream_metadata_changed when muting audio", async () => {
await call.setMicrophoneMuted(true);
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: {
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: {
mock_stream_from_media_handler: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: true,
@@ -972,8 +972,8 @@ describe("Call", function () {
it("should send sdp_stream_metadata_changed when muting video", async () => {
await call.setLocalVideoMuted(true);
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: {
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: {
mock_stream_from_media_handler: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: false,
@@ -1001,7 +1001,7 @@ describe("Call", function () {
);
call.onSDPStreamMetadataChangedReceived({
getContent: () => ({
[SDPStreamMetadataKey]: metadata,
[SDPStreamMetadataKey.name]: metadata,
}),
} as MatrixEvent);
return metadata;
@@ -1293,9 +1293,9 @@ describe("Call", function () {
FAKE_ROOM_ID,
EventType.CallNegotiate,
expect.objectContaining({
"version": "1",
"call_id": call.callId,
"org.matrix.msc3077.sdp_stream_metadata": expect.objectContaining({
version: "1",
call_id: call.callId,
sdp_stream_metadata: expect.objectContaining({
[SCREENSHARE_STREAM_ID]: expect.objectContaining({
purpose: SDPStreamMetadataPurpose.Screenshare,
}),
+2 -2
View File
@@ -963,7 +963,7 @@ describe("Group Call", function () {
const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent =>
({
getContent: () => ({
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
stream: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: audio,
@@ -1330,7 +1330,7 @@ describe("Group Call", function () {
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
call.onNegotiateReceived({
getContent: () => ({
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
screensharing_stream: {
purpose: SDPStreamMetadataPurpose.Screenshare,
},
+10 -4
View File
@@ -48,11 +48,17 @@ export interface IPasswordFlow extends ILoginFlow {
type: "m.login.password";
}
export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue(
"delegated_oidc_compatibility",
export const OAUTH_AWARE_PREFERRED_FLOW_FIELD = new UnstableValue(
"oauth_aware_preferred",
"org.matrix.msc3824.delegated_oidc_compatibility",
);
/**
* @alias
* @deprecated use `OAUTH_AWARE_PREFERRED_FLOW_FIELD` instead.
*/
export const DELEGATED_OIDC_COMPATIBILITY = OAUTH_AWARE_PREFERRED_FLOW_FIELD;
/**
* Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso
*/
@@ -60,8 +66,8 @@ export interface ISSOFlow extends ILoginFlow {
type: "m.login.sso" | "m.login.cas";
// eslint-disable-next-line camelcase
identity_providers?: IIdentityProvider[];
[DELEGATED_OIDC_COMPATIBILITY.name]?: boolean;
[DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean;
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name]?: boolean;
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName]?: boolean;
}
export enum IdentityProviderBrand {
+47 -6
View File
@@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd } from "matrix-events-sdk";
import { NamespacedValue, UnstableValue } from "../NamespacedValue.ts";
import {
type PolicyRuleEventContent,
@@ -50,7 +52,6 @@ import {
type MCallReplacesEvent,
type MCallSelectAnswer,
type SDPStreamMetadata,
type SDPStreamMetadataKey,
} from "../webrtc/callEventTypes.ts";
import {
type IRTCNotificationContent,
@@ -59,7 +60,7 @@ import {
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/membershipData/index.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
@@ -133,11 +134,13 @@ export enum EventType {
FullyRead = "m.fully_read",
Tag = "m.tag",
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
MarkedUnread = "m.marked_unread",
// User account_data events
PushRules = "m.push_rules",
Direct = "m.direct",
IgnoredUserList = "m.ignored_user_list",
InvitePermissionConfig = "m.invite_permission_config", // MSC4380
// to_device events
RoomKey = "m.room_key",
@@ -334,7 +337,16 @@ export interface TimelineEvents {
[EventType.CallCandidates]: MCallCandidates;
[EventType.CallHangup]: MCallHangupReject;
[EventType.CallReject]: MCallHangupReject;
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallSDPStreamMetadataChanged]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
@@ -386,6 +398,16 @@ export interface StateEvents {
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
}
/**
* Mapped type from event type to content type for all specified room-specific account_data events.
*/
export interface RoomAccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.FullyRead]: { event_id: string };
[EventType.Tag]: { tags: { [name: string]: { order?: number } } };
[EventType.SpaceOrder]: { order: string };
[EventType.MarkedUnread]: { unread: boolean };
}
/**
* Mapped type from event type to content type for all specified global account_data events.
*/
@@ -394,9 +416,12 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.Direct]: { [userId: string]: string[] };
[EventType.IgnoredUserList]: { ignored_users: { [userId: string]: EmptyObject } };
"m.secret_storage.default_key": { key: string };
// Flag set by the rust SDK (Element X) and also used by us to mark that the user opted out of backup
// (I don't know why it's m.org.matrix...)
// MSC4287: Sharing key backup preference between clients - used to mark that the user opted out of key storage
"m.key_backup": { enabled: boolean };
// MSC4287 unstable prefix (note the boolean property has the opposite sense)
"m.org.matrix.custom.backup_disabled": { disabled: boolean };
"m.identity_server": { base_url: string | null };
[key: `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}`]: LocalNotificationSettings;
[key: `m.secret_storage.key.${string}`]: SecretStorageKeyDescription;
@@ -404,8 +429,24 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
// Invites-ignorer events
[POLICIES_ACCOUNT_EVENT_TYPE.name]: { [key: string]: any };
[POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
[EventType.InvitePermissionConfig]: { default_action?: string };
// List of recently used reaction emojis
// https://spec.matrix.org/v1.18/client-server-api/#mrecent_emoji
"m.recent_emoji": {
recent_emoji: Array<{
emoji: string;
total: number;
}>;
};
}
/**
* Subset of AccountDataEvents, excluding events specified in https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-12
*/
export type WritableAccountDataEvents = Exclude<AccountDataEvents, "m.fully_read" | "m.push_rules">;
/**
* Mapped type from event type to content type for all specified global events encrypted by secret storage.
*
+3 -1
View File
@@ -58,7 +58,9 @@ export interface InviteOpts {
/**
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
* visibility was `shared`, via the experimental
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). If the room's current
* history visibility setting is neither `shared` nor `world_readable`, history sharing will be disabled to prevent
* exposing keys for messages sent prior to the visibility restriction.
*
* @experimental
*/
+1 -1
View File
@@ -42,7 +42,7 @@ import { type IMessageRendering } from "./extensible_events.ts";
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new NamespacedValue("m.topic");
export const M_TOPIC = new NamespacedValue("m.topic", null);
/**
* The event content for an m.topic event (in content)
+6 -6
View File
@@ -22,11 +22,11 @@ export class NamespacedValue<S extends string, U extends string> {
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
// Goal is to to have developers explicitly say there is no stable value (if applicable).
public constructor(stable: S, unstable: U);
public constructor(stable: S, unstable?: U);
public constructor(stable: null | undefined, unstable: U);
public constructor(stable: S, unstable: U | null);
public constructor(stable: null, unstable: U);
public constructor(
public readonly stable?: S | null,
public readonly unstable?: U,
public readonly stable: S | null,
public readonly unstable: U | null,
) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
@@ -60,8 +60,8 @@ 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): T | undefined {
let val: T | undefined = undefined;
public findIn<V>(obj: Partial<Record<NonNullable<S | U>, V>>): V | undefined {
let val: V | undefined = undefined;
if (this.name) {
val = obj?.[this.name];
}
+47 -22
View File
@@ -101,7 +101,7 @@ import {
type RoomNameState,
} from "./models/room.ts";
import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts";
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import { RoomStateEvent, type IPowerLevelsContent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import {
isSendDelayedEventRequestOpts,
UpdateDelayedEventAction,
@@ -138,6 +138,7 @@ import {
MsgType,
PUSHER_ENABLED,
RelationType,
type RoomAccountDataEvents,
RoomCreateTypeField,
RoomType,
type StateEvents,
@@ -145,6 +146,7 @@ import {
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
type WritableAccountDataEvents,
} from "./@types/event.ts";
import {
GuestAccess,
@@ -2025,6 +2027,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// attach the event listeners needed by RustCrypto
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
this.on(RoomStateEvent.Events, rustCrypto.onRoomStateEvent.bind(rustCrypto));
this.on(ClientEvent.Event, (event) => {
rustCrypto.onLiveEventFromSync(event);
});
@@ -2221,7 +2224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param eventType - The event type
* @param content - the contents object for the event
*/
public async setAccountData<K extends keyof AccountDataEvents>(
public async setAccountData<K extends keyof WritableAccountDataEvents>(
eventType: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
@@ -2273,7 +2276,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param eventType - The event type
* @param content - the contents object for the event
*/
public setAccountDataRaw<K extends keyof AccountDataEvents>(
public setAccountDataRaw<K extends keyof WritableAccountDataEvents>(
eventType: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
@@ -2328,7 +2331,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
public async deleteAccountData(eventType: keyof AccountDataEvents): Promise<void> {
public async deleteAccountData(eventType: keyof WritableAccountDataEvents): Promise<void> {
const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
// if deletion is not supported overwrite with empty content
if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
@@ -2426,12 +2429,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const roomId = res.room_id;
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
// Flag upfront that we are waiting for a key bundle, so that if we crash mid-import, we can try again.
await this.cryptoBackend.markRoomAsPendingKeyBundle(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);
}
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
}
// In case we were originally given an alias, check the room cache again
@@ -2584,12 +2585,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* @param roomId - the ID of the room this event should be stored within
* @param eventType - event type to be set
* @param content - event content
* @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response.
*/
public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<EmptyObject> {
public setRoomAccountData<K extends keyof RoomAccountDataEvents>(
roomId: string,
eventType: K,
content: RoomAccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
$userId: this.credentials.userId!,
$roomId: roomId,
@@ -3911,7 +3917,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Rejects: with an error response.
* May return synthesized attributes if the URL lacked OG meta.
*/
public getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
public async getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
// bucket the timestamp to the nearest minute to prevent excessive spam to the server
// Surely 60-second accuracy is enough for anyone.
ts = Math.floor(ts / 60000) * 60000;
@@ -3927,16 +3933,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.urlPreviewCache[key];
}
const supportsNewEndpoint = await this.isVersionSupported("v1.11");
const resp = this.http.authedRequest<IPreviewUrlResponse>(
Method.Get,
"/preview_url",
supportsNewEndpoint ? "/media/preview_url" : "/preview_url",
{
url,
ts: ts.toString(),
},
undefined,
{
prefix: MediaPrefix.V3,
prefix: supportsNewEndpoint ? ClientPrefix.V1 : MediaPrefix.V3,
priority: "low",
},
);
@@ -4079,7 +4087,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (opts.shareEncryptedHistory) {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
const historyVisibility = this.getRoom(roomId)?.getHistoryVisibility() ?? HistoryVisibility.Shared;
// We should only share room history if the *current* visibility allows it.
if ([HistoryVisibility.Invited, HistoryVisibility.Joined].includes(historyVisibility)) {
this.logger.debug("Not sharing message history as the room history visibility is currently unshared");
} else {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
}
}
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
@@ -6689,6 +6703,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const params = {
redirectUrl,
[SSO_ACTION_PARAM.stable!]: action,
[SSO_ACTION_PARAM.unstable!]: action,
};
@@ -6936,13 +6951,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Upgrades a room to a new protocol version
* @param newVersion - The target version to upgrade to
* @param additionalCreators - an optional list of user IDs of users who
* should have the same permissions as the user performing the
* upgrade
* @returns Promise which resolves: Object with key 'replacement_room'
* @returns Rejects: with an error response.
*/
public upgradeRoom(roomId: string, newVersion: string): Promise<{ replacement_room: string }> {
public upgradeRoom(
roomId: string,
newVersion: string,
additionalCreators?: string[],
): Promise<{ replacement_room: string }> {
// eslint-disable-line camelcase
const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId });
return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion });
return this.http.authedRequest(Method.Post, path, undefined, {
new_version: newVersion,
additional_creators: additionalCreators,
});
}
/**
@@ -8826,21 +8851,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Discover and validate delegated auth configuration
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* Discover and validate the auth metadata for the OAuth 2.0 API.
*
* Fetches /auth_metadata falling back to legacy implementation using /auth_issuer followed by
* https://oidc-issuer.example.com/.well-known/openid-configuration and other files linked therein.
* When successful, validated metadata is returned
* When successful, validated metadata is returned.
*
* @returns validated authentication metadata and optionally signing keys
* @throws when delegated auth config is invalid or unreachable
* @experimental - part of MSC2965
*/
public async getAuthMetadata(): Promise<OidcClientConfig> {
let authMetadata: unknown | undefined;
try {
const useStable = await this.isVersionSupported("v1.15");
authMetadata = await this.http.request<unknown>(Method.Get, "/auth_metadata", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
prefix: useStable ? ClientPrefix.V1 : ClientPrefix.Unstable + "/org.matrix.msc2965",
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
+13 -1
View File
@@ -80,6 +80,18 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
/**
* Having accepted an invite for the given room from the given user, attempt to
* find information about a room key bundle and, if found, download the
@@ -103,7 +115,7 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @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;
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api
+3 -3
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { type MBeaconEventContent, type MBeaconInfoContent, type MBeaconInfoEventContent } from "./@types/beacon.ts";
import { MsgType } from "./@types/event.ts";
import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { type IMessageRendering, M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { isProvided } from "./extensible_events_v1/utilities.ts";
import {
M_ASSET,
@@ -29,7 +29,7 @@ import {
type MAssetContent,
type LegacyLocationEventContent,
} from "./@types/location.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC } from "./@types/topic.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC, type MTopicEvent } from "./@types/topic.ts";
import { type RoomMessageEventContent } from "./@types/events.ts";
/**
@@ -206,7 +206,7 @@ export type TopicState = {
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopicParent = M_TOPIC.findIn<MTopicContent>(content);
const mtopicParent = M_TOPIC.findIn<MTopicContent | IMessageRendering[]>(content as MTopicEvent);
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
+5
View File
@@ -51,6 +51,7 @@ function validateMediaId(mediaId: string): boolean {
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @param animated - Whether the desired thumbnail should be animated.
* @returns The complete URL to the content, may be an empty string if the provided mxc is not valid.
*/
export function getHttpUriForMxc(
@@ -62,6 +63,7 @@ export function getHttpUriForMxc(
allowDirectLinks = false,
allowRedirects?: boolean,
useAuthentication?: boolean,
animated?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
@@ -107,6 +109,9 @@ export function getHttpUriForMxc(
if (resizeMethod) {
url.searchParams.set("method", resizeMethod);
}
if (animated !== undefined) {
url.searchParams.set("animated", String(animated));
}
if (typeof allowRedirects === "boolean") {
// We add this after, so we don't convert everything to a thumbnail request.
+53 -21
View File
@@ -188,7 +188,9 @@ export interface CryptoApi {
/**
* Check if the given user has published cross-signing keys.
*
* - If the user is tracked, a `/keys/query` request is made to update locally the cross signing keys.
* - If the user is this user, a `/keys/query` request is made to update locally the cross signing keys.
* - If the user is tracked, any current `/keys/query` requests are awaited (with a timeout) and then
* the locally cached information is used.
* - If the user is not tracked locally and downloadUncached is set to true,
* a `/keys/query` request is made to the server to retrieve the cross signing keys.
* - Otherwise, return false
@@ -205,7 +207,10 @@ export interface CryptoApi {
* Get the device information for the given list of users.
*
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
* cached device data is returned.
* cached device data is returned, unless it is stale.
*
* If there are users with stale cached entries, wait (with some timeout) for any in-progress
* `/keys/query` request to complete.
*
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
* a `/keys/query` request is made to the server to retrieve these devices.
@@ -563,8 +568,11 @@ export interface CryptoApi {
* if they match, stores the key in the crypto store by calling {@link storeSessionBackupPrivateKey},
* which enables automatic restore of individual keys when an Unable-to-decrypt error is encountered.
*
* If we are unable to fetch the key from secret storage, there is no backup on the server, or the key
* does not match, throws an exception.
* If the backup decryption key from secret storage does not match the
* latest backup on the server, we throw a {@link DecryptionKeyDoesNotMatchError}.
*
* If we are unable to fetch the key from secret storage or there is no backup on the server,
* we throw an exception.
*/
loadSessionBackupPrivateKeyFromSecretStorage(): Promise<void>;
@@ -623,7 +631,6 @@ export interface CryptoApi {
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
* known 4S data (cross-signing keys and the megolm key backup key).
* * Deletes any dehydrated devices.
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
*/
disableKeyStorage(): Promise<void>;
@@ -717,20 +724,6 @@ export interface CryptoApi {
* @param secrets - The secrets bundle received from the other device
*/
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
*
* @experimental
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
}
/** A reason code for a failure to decrypt an event. */
@@ -805,6 +798,9 @@ export enum DeviceIsolationModeKind {
*
* Events from all senders are always decrypted (and should be decorated with message shields in case
* of authenticity warnings, see {@link EventEncryptionInfo}).
*
* `AllDevicesIsolationMode` is used in the legacy, non-'exclude insecure devices' mode in Element Web. It is not
* recommended (see {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4153 | MSC4153}).
*/
export class AllDevicesIsolationMode {
public readonly kind = DeviceIsolationModeKind.AllDevicesIsolationMode;
@@ -831,6 +827,9 @@ export class AllDevicesIsolationMode {
*
* Events are decrypted only if they come from a cross-signed device. Other events will result in a decryption
* failure. (To access the failure reason, see {@link MatrixEvent.decryptionFailureReason}.)
*
* `OnlySignedDevicesIsolationMode` corresponds to the 'Exclude insecure devices' mode in Element Web, which is
* recommended by {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4153 | MSC4153}.
*/
export class OnlySignedDevicesIsolationMode {
public readonly kind = DeviceIsolationModeKind.OnlySignedDevicesIsolationMode;
@@ -862,6 +861,25 @@ export interface BootstrapCrossSigningOpts {
* Represents the ways in which we trust a user
*/
export class UserVerificationStatus {
/**
* Indicates if we have saved a known identity for this user. Typically, this means that we share a
* room with them (or have done in the past).
*
* If this is `false`, then the other flags ({@link isCrossSigningVerified}, {@link wasCrossSigningVerified},
* {@link needsUserApproval}) will also be `false`. This means that we haven't seen this user before.
*
* If this is `true`, then there are further possibilities:
*
* - If {@link isCrossSigningVerified} returns `true`, then we have cryptographically verified the current
* identity of this user: that is the highest form of trust we have.
*
* - If {@link needsUserApproval} is `true`, that means that the user has changed their identity.
*
* - Otherwise, the user is "TOFU trusted": we have a record of their identity, and, typically, will share
* encrypted content with them as long as they retain that identity.
*/
public readonly known: boolean;
/**
* Indicates if the identity has changed in a way that needs user approval.
*
@@ -877,12 +895,14 @@ export class UserVerificationStatus {
*/
public readonly needsUserApproval: boolean;
/** @internal */
public constructor(
private readonly crossSigningVerified: boolean,
private readonly crossSigningVerifiedBefore: boolean,
private readonly tofu: boolean,
known: boolean,
needsUserApproval: boolean = false,
) {
this.known = known;
this.needsUserApproval = needsUserApproval;
}
@@ -914,7 +934,7 @@ export class UserVerificationStatus {
* @deprecated No longer supported, with the Rust crypto stack.
*/
public isTofu(): boolean {
return this.tofu;
return false;
}
}
@@ -1339,6 +1359,18 @@ export interface OlmEncryptionInfo {
senderVerified: boolean;
}
/**
* An error thrown by loadSessionBackupPrivateKeyFromSecretStorage indicating
* that the decryption key found in secret storage does not match the public key
* of the latest backup.
*/
export class DecryptionKeyDoesNotMatchError extends Error {
public constructor(message: string) {
super(message);
this.name = "DecryptionKeyDoesNotMatchError";
}
}
export * from "./verification.ts";
export type * from "./keybackup.ts";
export * from "./recovery-key.ts";
+111 -55
View File
@@ -38,6 +38,7 @@ import {
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
UpdateDelayedEventAction,
isSendDelayedEventRequestOpts,
} from "./@types/requests.ts";
import { EventType, type StateEvents } from "./@types/event.ts";
import { logger } from "./logger.ts";
@@ -56,7 +57,7 @@ import { ConnectionError, MatrixError } from "./http-api/errors.ts";
import { User } from "./models/user.ts";
import { type Room } from "./models/room.ts";
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
import { MapWithDefault, type QueryDict, recursiveMapToObject } from "./utils.ts";
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
interface IStateEventRequest {
@@ -122,6 +123,20 @@ export interface ICapabilities {
* @defaultValue false
*/
updateDelayedEvents?: boolean;
/**
* Whether this client needs to be able to send sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
sendSticky?: boolean;
/**
* Whether this client needs to be able to receive sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
receiveSticky?: boolean;
}
export enum RoomWidgetClientEvent {
@@ -197,54 +212,7 @@ export class RoomWidgetClient extends MatrixClient {
)
: Promise.resolve();
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length ||
capabilities.receiveEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.receiveMessage === true ||
(Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) ||
capabilities.sendState?.length ||
capabilities.receiveState?.length
) {
widgetApi.requestCapabilityForRoomTimeline(roomId);
}
capabilities.sendEvent?.forEach((eventType) => widgetApi.requestCapabilityToSendEvent(eventType));
capabilities.receiveEvent?.forEach((eventType) => widgetApi.requestCapabilityToReceiveEvent(eventType));
if (capabilities.sendMessage === true) {
widgetApi.requestCapabilityToSendMessage();
} else if (Array.isArray(capabilities.sendMessage)) {
capabilities.sendMessage.forEach((msgType) => widgetApi.requestCapabilityToSendMessage(msgType));
}
if (capabilities.receiveMessage === true) {
widgetApi.requestCapabilityToReceiveMessage();
} else if (Array.isArray(capabilities.receiveMessage)) {
capabilities.receiveMessage.forEach((msgType) => widgetApi.requestCapabilityToReceiveMessage(msgType));
}
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
widgetApi.requestCapabilityToSendState(eventType, stateKey),
);
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
);
capabilities.sendToDevice?.forEach((eventType) => widgetApi.requestCapabilityToSendToDevice(eventType));
capabilities.receiveToDevice?.forEach((eventType) => widgetApi.requestCapabilityToReceiveToDevice(eventType));
if (
capabilities.sendDelayedEvents &&
(capabilities.sendEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.sendState?.length)
) {
widgetApi.requestCapability(MatrixCapabilities.MSC4157SendDelayedEvent);
}
if (capabilities.updateDelayedEvents) {
widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent);
}
if (capabilities.turnServers) {
widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
this.requestInitialCapabilities(capabilities, roomId);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
@@ -260,10 +228,70 @@ export class RoomWidgetClient extends MatrixClient {
if (sendContentLoaded) widgetApi.sendContentLoaded();
}
private requestInitialCapabilities(capabilities: ICapabilities, roomId: string): void {
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length ||
capabilities.receiveEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.receiveMessage === true ||
(Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) ||
capabilities.sendState?.length ||
capabilities.receiveState?.length
) {
this.widgetApi.requestCapabilityForRoomTimeline(roomId);
}
capabilities.sendEvent?.forEach((eventType) => this.widgetApi.requestCapabilityToSendEvent(eventType));
capabilities.receiveEvent?.forEach((eventType) => this.widgetApi.requestCapabilityToReceiveEvent(eventType));
if (capabilities.sendMessage === true) {
this.widgetApi.requestCapabilityToSendMessage();
} else if (Array.isArray(capabilities.sendMessage)) {
capabilities.sendMessage.forEach((msgType) => this.widgetApi.requestCapabilityToSendMessage(msgType));
}
if (capabilities.receiveMessage === true) {
this.widgetApi.requestCapabilityToReceiveMessage();
} else if (Array.isArray(capabilities.receiveMessage)) {
capabilities.receiveMessage.forEach((msgType) => this.widgetApi.requestCapabilityToReceiveMessage(msgType));
}
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
this.widgetApi.requestCapabilityToSendState(eventType, stateKey),
);
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
this.widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
);
capabilities.sendToDevice?.forEach((eventType) => this.widgetApi.requestCapabilityToSendToDevice(eventType));
capabilities.receiveToDevice?.forEach((eventType) =>
this.widgetApi.requestCapabilityToReceiveToDevice(eventType),
);
if (
capabilities.sendDelayedEvents &&
(capabilities.sendEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.sendState?.length)
) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4157SendDelayedEvent);
}
if (capabilities.updateDelayedEvents) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent);
}
if (capabilities.sendSticky) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4407SendStickyEvent);
}
if (capabilities.receiveSticky) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4407ReceiveStickyEvent);
}
if (capabilities.turnServers) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
}
public async supportUpdateState(): Promise<boolean> {
return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
}
private readonly syncApiResolver = Promise.withResolvers<void>();
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
this.lifecycle = new AbortController();
@@ -282,6 +310,7 @@ export class RoomWidgetClient extends MatrixClient {
} else {
this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
}
this.syncApiResolver.resolve();
this.room = this.syncApi.createRoom(this.roomId);
this.store.storeRoom(this.room);
@@ -346,17 +375,41 @@ export class RoomWidgetClient extends MatrixClient {
throw new Error(`Unknown room: ${roomIdOrAlias}`);
}
protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts,
): Promise<SendDelayedEventResponse>;
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts,
delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
let queryOpts = queryDict;
let delayOpts: SendDelayedEventRequestOpts | undefined;
if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) {
delayOpts = delayOptsOrQuery;
} else if (!queryOpts) {
queryOpts = delayOptsOrQuery;
}
const stickyDurationMs = queryOpts?.["org.matrix.msc4354.sticky_duration_ms"];
if (stickyDurationMs !== undefined && typeof stickyDurationMs !== "number") {
throw new Error("Sticky duration must be a number when defined");
}
// This is save since we just checked that above
// We need the additional as assertion for the EW linter to be happy.
// It is not capable of implying the type based on the throw if `stickyDurationMs !== undefined && typeof stickyDurationMs !== "number"`
// above
const stickyDurationMsAsNumber: number | undefined = stickyDurationMs as number | undefined;
// We need to extend the content with the redacts parameter
// The js sdk uses event.redacts but the widget api uses event.content.redacts
// This will be converted back to event.redacts in the widget driver.
@@ -374,6 +427,7 @@ export class RoomWidgetClient extends MatrixClient {
room.roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
stickyDurationMsAsNumber,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
@@ -386,7 +440,7 @@ export class RoomWidgetClient extends MatrixClient {
let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi
.sendRoomEvent(event.getType(), content, room.roomId)
.sendRoomEvent(event.getType(), content, room.roomId, undefined, undefined, stickyDurationMsAsNumber)
.catch(timeoutToConnectionError);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
@@ -683,7 +737,7 @@ export class RoomWidgetClient extends MatrixClient {
// Only inject once we have update the txId
await this.updateTxId(event);
await this.syncApiResolver.promise;
if (this.syncApi instanceof SyncApi) {
if (await this.supportUpdateState()) {
await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
@@ -704,6 +758,7 @@ export class RoomWidgetClient extends MatrixClient {
}
this.emit(ClientEvent.Event, event);
if (event.unstableStickyInfo !== undefined) this.room!._unstable_addStickyEvents([event]);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()}`);
} else {
@@ -737,6 +792,7 @@ export class RoomWidgetClient extends MatrixClient {
"received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'",
);
}
await this.syncApiResolver.promise;
for (const rawEvent of ev.detail.data.state) {
// Verify the room ID matches, since it's possible for the client to
// send us state updates from other rooms if this widget is always
@@ -750,7 +806,7 @@ export class RoomWidgetClient extends MatrixClient {
// Sliding Sync
await this.syncApi!.injectRoomEvents(this.room!, [event]);
}
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
logger.debug(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
+5
View File
@@ -67,6 +67,11 @@ export interface IHttpOpts {
* Optional, only called when a refreshToken is present
*/
tokenRefreshFunction?: TokenRefreshFunction;
/**
* Whether to use the HTTP Authorization header over the `access_token` query parameter
* @deprecated as of v1.11 in https://spec.matrix.org/v1.17/client-server-api/#using-access-tokens
*/
useAuthorizationHeader?: boolean; // defaults to true
/** For historical reasons, must be set to `true`. Will eventually be removed. */
+5 -5
View File
@@ -52,35 +52,35 @@ export interface BaseLogger {
*
* @param msg - Data to log.
*/
trace(...msg: any[]): void;
trace(this: void, ...msg: any[]): void;
/**
* Output debug message to the logger.
*
* @param msg - Data to log.
*/
debug(...msg: any[]): void;
debug(this: void, ...msg: any[]): void;
/**
* Output info message to the logger.
*
* @param msg - Data to log.
*/
info(...msg: any[]): void;
info(this: void, ...msg: any[]): void;
/**
* Output warn message to the logger.
*
* @param msg - Data to log.
*/
warn(...msg: any[]): void;
warn(this: void, ...msg: any[]): void;
/**
* Output error message to the logger.
*
* @param msg - Data to log.
*/
error(...msg: any[]): void;
error(this: void, ...msg: any[]): void;
}
// This is to demonstrate, that you can use any namespace you want.
+1
View File
@@ -82,6 +82,7 @@ export * from "./models/room-summary.ts";
export * from "./models/event-status.ts";
export * from "./models/profile-keys.ts";
export * from "./models/related-relations.ts";
export { type StickyMatrixEvent, RoomStickyEventsEvent } from "./models/room-sticky-events.ts";
export type { RoomSummary } from "./client.ts";
export * as ContentHelpers from "./content-helpers.ts";
export * as SecretStorage from "./secret-storage.ts";
+203 -376
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,16 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MXID_PATTERN } from "../models/room-member.ts";
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 MatrixEvent, type IContent } from "../models/event.ts";
import { type RelationType } from "../@types/event.ts";
import { sha256 } from "../digest.ts";
import { encodeUnpaddedBase64 } from "../base64.ts";
import { type Logger } from "../logger.ts";
import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type Logger, logger } from "../logger.ts";
import { computeSlotId, slotIdToDescription } from "./utils.ts";
import {
checkRtcMembershipData,
computeRtcIdentityRaw,
type RtcMembershipData,
checkSessionsMembershipData,
type SessionMembershipData,
MatrixRTCMembershipParseError,
} from "./membershipData/index.ts";
import { EventType } from "../@types/event.ts";
/**
* The default duration in milliseconds that a membership is considered valid for.
@@ -32,331 +36,118 @@ import { type 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;
/**
* Describes the source event type that provided the membership data.
*/
enum MembershipKind {
/**
* 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}`.
* The modern MSC4143 format event.
*/
id: string;
};
export interface RtcMembershipData {
"slot_id": string;
"member": Member;
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": {
type: string;
// other application specific keys
[key: string]: unknown;
};
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
RTC = "rtc",
/**
* The legacy call event type.
*/
Session = "session",
}
const checkRtcMembershipData = (
data: IContent,
errors: string[],
referenceUserId: string,
): data is RtcMembershipData => {
const prefix = " - ";
type MembershipData =
| { kind: MembershipKind.RTC; data: RtcMembershipData }
| { kind: MembershipKind.Session; data: SessionMembershipData };
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else {
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
return errors.length === 0;
};
/**
* MSC4143 (MatrixRTC) session membership data.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
*/
"focus_active": LivekitFocusSelection;
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: CallScope;
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
*/
"msc4354_sticky_key"?: string;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
const prefix = " - ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist)
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
return errors.length === 0;
};
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
type LimitedEvent = Pick<MatrixEvent, "getId" | "getSender" | "getTs" | "getType" | "getContent">;
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership is removed, to avoid confusion.
export class CallMembership {
/**
* Parse the membershipdata from a call membership event.
* @param matrixEvent The Matrix event to read.
* @returns MembershipData in either MembershipKind.RTC or MembershipKind.Session format.
* @throws If the content is neither format.
*/
public static membershipDataFromMatrixEvent(matrixEvent: LimitedEvent): MembershipData {
const sender = matrixEvent.getSender();
const evType = matrixEvent.getType();
const data = matrixEvent.getContent();
if (sender === undefined) throw new Error("matrixEvent is missing sender field");
try {
// Event types are strictly checked here.
if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) {
return { kind: MembershipKind.RTC, data };
} else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) {
return { kind: MembershipKind.Session, data };
} else {
throw Error(`'${evType} is not a known call membership type`);
}
} catch (ex) {
if (ex instanceof MatrixRTCMembershipParseError) {
logger.debug("CallMembership.MatrixRTCMembershipParseError provided invalid data", data);
}
throw ex;
}
}
/**
* Parse the contents of a MatrixEvent and create a CallMembership instance.
* @param matrixEvent The Matrix event to read.
*/
public static async parseFromEvent(matrixEvent: LimitedEvent): Promise<CallMembership> {
const membershipData: MembershipData = this.membershipDataFromMatrixEvent(matrixEvent);
const rtcBackendIdentity =
membershipData.kind === MembershipKind.RTC
? await computeRtcIdentityRaw(
membershipData.data.member.user_id,
membershipData.data.member.device_id,
membershipData.data.member.id,
)
: `${matrixEvent.getSender()}:${membershipData.data.device_id}`;
return new CallMembership(matrixEvent, membershipData, rtcBackendIdentity);
}
public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a?.membershipData, b?.membershipData);
}
private logger?: Logger;
private logger: Logger;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string; ts: number };
public constructor(
/** 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)}
*
*/
private readonly membershipData: MembershipData,
/**
*
* Anonymized identity to use with the RTC backend.
*
* 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.
*/
public readonly rtcBackendIdentity: string,
/**
* The constructor will automatically create a properly tagged child logger instance.
*/
logger?: Logger,
) {
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}]`);
}
private readonly matrixEventData: { eventId: string; sender: string };
/**
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
* `${this.userId}:${this.deviceId}` for state events (kind = session)
* Use `parseFromEvent`.
* Constructor should only be used by tests.
* @private
* @param matrixEvent
* @param membershipData
* @param rtcBackendIdentity
*/
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> {
return encodeUnpaddedBase64(await sha256(`${userId}|${deviceId}|${memberId}`));
}
public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData {
const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()];
public constructor(
/** The Matrix event that this membership is based on */
private readonly matrixEvent: LimitedEvent,
private readonly membershipData: MembershipData,
public readonly rtcBackendIdentity: string,
) {
const eventId = matrixEvent.getId();
const sender = matrixEvent.getSender();
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
const sessionErrors: string[] = [];
const rtcErrors: string[] = [];
if (checkSessionsMembershipData(content, sessionErrors)) {
return { kind: "session", data: content };
} else if (checkRtcMembershipData(content, rtcErrors, sender)) {
return { kind: "rtc", data: content };
} else {
const details =
sessionErrors.length < rtcErrors.length
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
throw Error(`unknown CallMembership data.\n` + details + json);
}
this.logger = logger.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
this.matrixEventData = { eventId, sender };
}
/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.member.user_id;
case "session":
case MembershipKind.Session:
default:
return this.matrixEventData.sender;
}
@@ -372,88 +163,122 @@ export class CallMembership {
*/
public get slotId(): string {
const { kind, data } = this.membershipData;
if (data.application === "m.call") {
switch (kind) {
case MembershipKind.RTC:
return data.slot_id;
case MembershipKind.Session:
default: {
const [application, id] = [data.application, data.call_id];
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
// State events still are sent with `""` however. To find other events that should end up in the same call,
// we use the slotId.
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
// "ROOM" slotId/call_id.
// This makes all the remote members work with just this simple trick.
//
// We of course now need to be careful when sending legacy events (state events)
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
// is commited.
//
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
let compatibilityAdaptedId: string;
if (id === "") {
compatibilityAdaptedId = "ROOM";
this.logger?.info("use slotId compat hack emptyString -> ROOM");
} else {
compatibilityAdaptedId = id;
}
return computeSlotId({
application,
id: compatibilityAdaptedId,
});
}
}
}
this.logger?.info("NOT using slotId compat hack emptyString -> ROOM");
// This is what the function should look like for any other application that did not
// go through a `""`=> `"ROOM"` rename
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.slot_id;
case "session":
case MembershipKind.Session:
default:
return slotDescriptionToId({ application: this.application, id: data.call_id });
return computeSlotId({ application: data.application, id: data.call_id });
}
}
public get deviceId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.member.device_id;
case "session":
case MembershipKind.Session:
default:
return data.device_id;
}
}
public get callIntent(): RTCCallIntent | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc": {
const intent = data.application["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger?.warn("RTC membership has invalid m.call.intent");
return undefined;
}
case "session":
default:
return data["m.call.intent"];
const intent = this.applicationData["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger.warn("RTC membership has invalid m.call.intent");
return undefined;
}
/**
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
*/
public get slotDescription(): SlotDescription {
const { kind, data } = this.membershipData;
if (kind === MembershipKind.RTC) {
const id = data.slot_id.slice(`${data.application.type}#`.length);
return { application: data.application.type, id };
}
return slotIdToDescription(this.slotId);
}
/**
* The application `type`.
* @deprecated Use @see applicationData
*/
public get application(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application.type;
case "session":
default:
return data.application;
}
return this.applicationData.type;
}
/**
* Information about the application being used for the RTC session.
* May contain extra keys specific to the application.
*/
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.application;
case "session":
case MembershipKind.Session:
default:
// SessionData does not have application data as such. We return specific
// properties in use by other getters in this class, for compatibility.
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
}
}
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
public get scope(): CallScope | undefined {
public get scope(): SessionMembershipData["scope"] | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return undefined;
case "session":
case MembershipKind.Session:
default:
return data.scope;
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
/**
* This computes the membership ID for the membership.
@@ -478,25 +303,33 @@ export class CallMembership {
case "rtc":
return data.member.id;
case "session":
default:
return (
// best case we have a client already publishing the right custom membershipId
data.membershipID ??
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
`${this.matrixEventData.sender}:${data.device_id}`
);
default:
throw Error("Not possible to get memberID without knowing the membership event kind");
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
public createdTs(): number {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEventData.ts;
case "session":
return this.matrixEvent.getTs();
case MembershipKind.Session:
default:
return data.created_ts ?? this.matrixEventData.ts;
return data.created_ts ?? this.matrixEvent.getTs();
}
}
@@ -507,9 +340,9 @@ export class CallMembership {
public getAbsoluteExpiry(): number | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return undefined;
case "session":
case MembershipKind.Session:
default:
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
@@ -518,19 +351,20 @@ export class CallMembership {
/**
* @returns The number of milliseconds until the membership expires or undefined if applicable
* @deprecated Not used by RTC events.
*/
public getMsUntilExpiry(): number | undefined {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
if (kind === MembershipKind.Session) {
const absExpiry = this.getAbsoluteExpiry();
if (absExpiry) {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry()! - Date.now();
return absExpiry - Date.now();
}
}
return undefined;
}
/**
@@ -539,9 +373,9 @@ export class CallMembership {
public isExpired(): boolean {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return false;
case "session":
case MembershipKind.Session:
default:
return this.getMsUntilExpiry()! <= 0;
}
@@ -567,30 +401,26 @@ export class CallMembership {
public getTransport(oldestMembership: CallMembership): Transport | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.rtc_transports[0];
case "session":
case MembershipKind.Session:
switch (data.focus_active.focus_selection) {
case "multi_sfu":
return data.foci_preferred[0];
case "oldest_membership":
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
break;
case "multi_sfu":
return data.foci_preferred[0];
default:
// `focus_selection` not understood.
return undefined;
}
break;
default:
return undefined;
}
return undefined;
}
/**
* The focus_active filed of the session membership (m.call.member).
* @deprecated focus_active is not used and will be removed in future versions.
*/
public getFocusActive(): LivekitFocusSelection | undefined {
const { kind, data } = this.membershipData;
if (kind === "session") return data.focus_active;
return undefined;
}
/**
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
@@ -598,14 +428,11 @@ export class CallMembership {
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.rtc_transports;
case "session":
case MembershipKind.Session:
default:
return data.foci_preferred;
}
}
public get kind(): MembershipData["kind"] {
return this.membershipData.kind;
}
}
+1 -417
View File
@@ -1,11 +1,6 @@
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { secureRandomBase64Url } from "../randomstring.ts";
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 EncryptionKeyMapKey, type Statistics } from "./types.ts";
import { type EncryptionKeyMapKey } from "./types.ts";
/**
* The string used for the keys in the the encryption key map.
@@ -57,414 +52,3 @@ export interface IEncryptionManager {
}
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:
* - generate new keys for the local user and send them to other participants
* - track all keys of all other members and update livekit.
*
* @internal
*/
export class EncryptionManager implements IEncryptionManager {
private manageMediaKeys = false;
private keysEventUpdateTimeout?: ReturnType<typeof setTimeout>;
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
private get updateEncryptionKeyThrottle(): number {
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
}
private get makeKeyDelay(): number {
return this.joinConfig?.makeKeyDelay ?? 3_000;
}
private get useKeyDelay(): number {
return this.joinConfig?.useKeyDelay ?? 5_000;
}
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
// if it looks like a membership has been updated.
private lastMembershipFingerprints: Set<string> | undefined;
private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined;
private logger: Logger;
public constructor(
private membership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => void,
parentLogger?: Logger,
) {
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
}
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 EncryptionKeyMapKey, keys);
}
return keysMap;
}
private joined = false;
public join(joinConfig: EncryptionConfig): void {
this.joinConfig = joinConfig;
this.joined = true;
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.start();
if (this.joinConfig?.manageMediaKeys) {
this.makeNewSenderKey();
this.requestSendCurrentKey();
}
}
public leave(): void {
// 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(getEncryptionKeyMapKey(this.membership), []);
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
if (this.makeNewKeyTimeout !== undefined) {
clearTimeout(this.makeNewKeyTimeout);
this.makeNewKeyTimeout = undefined;
}
for (const t of this.setNewKeyTimeouts) {
clearTimeout(t);
}
this.setNewKeyTimeouts.clear();
this.manageMediaKeys = false;
this.joined = false;
}
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
if (this.manageMediaKeys && this.joined) {
const oldMembershipIds = new Set(
oldMemberships
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map(getEncryptionKeyMapKey),
);
const newMembershipIds = new Set(
this.getMemberships()
.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
// for this once available
const anyLeft = Array.from(oldMembershipIds).some((x) => !newMembershipIds.has(x));
const anyJoined = Array.from(newMembershipIds).some((x) => !oldMembershipIds.has(x));
const oldFingerprints = this.lastMembershipFingerprints;
// always store the fingerprints of these latest memberships
this.storeLastMembershipFingerprints();
if (anyLeft) {
if (this.makeNewKeyTimeout) {
// existing rotation in progress, so let it complete
} else {
this.logger.debug(`Member(s) have left: queueing sender key rotation`);
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay);
}
} else if (anyJoined) {
this.logger.debug(`New member(s) have joined: re-sending keys`);
this.requestSendCurrentKey();
} else if (oldFingerprints) {
// does it look like any of the members have updated their memberships?
const newFingerprints = this.lastMembershipFingerprints!;
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
// for this once available
const candidateUpdates =
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
if (candidateUpdates) {
this.logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
this.requestSendCurrentKey();
}
}
}
}
/**
* Generate a new sender key and add it at the next available index
* @param delayBeforeUse - If true, wait for a short period before setting the key for the
* media encryptor to use. If false, set the key immediately.
* @returns The index of the new key
*/
private makeNewSenderKey(delayBeforeUse = false): number {
const encryptionKey = secureRandomBase64Url(16);
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
this.logger.info("Generated new key at index " + encryptionKeyIndex);
this.setEncryptionKey(this.membership, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
return encryptionKeyIndex;
}
/**
* Requests that we resend our current keys to the room. May send a keys event immediately
* or queue for alter if one has already been sent recently.
*/
private requestSendCurrentKey(): void {
if (!this.manageMediaKeys) return;
if (
this.lastEncryptionKeyUpdateRequest &&
this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now()
) {
this.logger.info("Last encryption key event sent too recently: postponing");
if (this.keysEventUpdateTimeout === undefined) {
this.keysEventUpdateTimeout = setTimeout(
() => void this.sendEncryptionKeysEvent(),
this.updateEncryptionKeyThrottle,
);
}
return;
}
void this.sendEncryptionKeysEvent();
}
/**
* Get the known encryption keys for a given participant device.
*
* @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(membership: CallMembershipIdentityParts): Array<Uint8Array<ArrayBuffer>> | undefined {
return this.encryptionKeys.get(getEncryptionKeyMapKey(membership))?.map((entry) => entry.key);
}
/**
* Re-sends the encryption keys room event
*/
private sendEncryptionKeysEvent = async (indexToSend?: number): Promise<void> => {
if (this.keysEventUpdateTimeout !== undefined) {
clearTimeout(this.keysEventUpdateTimeout);
this.keysEventUpdateTimeout = undefined;
}
this.lastEncryptionKeyUpdateRequest = Date.now();
if (!this.joined) return;
const myKeys = this.getKeysForParticipant(this.membership);
if (!myKeys) {
this.logger.warn("Tried to send encryption keys event but no keys found!");
return;
}
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
this.logger.warn("Tried to send encryption keys event but no current key index found!");
return;
}
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
this.logger.info(
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
);
const keyToSend = myKeys[keyIndexToSend];
try {
this.statistics.counters.roomEventEncryptionKeysSent += 1;
const targets = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.membership.userId}:${this.membership.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
);
} catch (error) {
if (this.keysEventUpdateTimeout === undefined) {
const resendDelay = safeGetRetryAfterMs(error, 5000);
this.logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
} else {
this.logger.info("Not scheduling key resend as another re-send is already pending");
}
}
};
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.membership.userId, this.membership.deviceId))
.map((m) => `${getEncryptionKeyMapKey(m)}:${m.createdTs()}`),
);
}
private getNewEncryptionKeyIndex(): number {
if (this.latestGeneratedKeyIndex === -1) {
return 0;
}
// maximum key index is 255
return (this.latestGeneratedKeyIndex + 1) % 256;
}
/**
* Sets an encryption key at a specified index for a participant.
* The encryption keys for the local participant are also stored here under the
* user and device ID of the local participant.
* If the key is older than the existing key at the index, it will be ignored.
* @param userId - The user ID of the participant
* @param deviceId - Device ID of the participant
* @param encryptionKeyIndex - The index of the key to set
* @param encryptionKeyString - The string representation of the key to set in base64
* @param timestamp - The timestamp of the key. We assume that these are monotonic for each participant device.
* @param delayBeforeUse - If true, delay before emitting a key changed event. Useful when setting
* encryption keys for the local participant to allow time for the key to
* be distributed.
*/
private setEncryptionKey(
membership: CallMembershipIdentityParts,
encryptionKeyIndex: number,
encryptionKeyString: string,
timestamp: number,
delayBeforeUse = false,
): void {
this.logger.debug(
`Setting encryption key for ${membership.userId}:${membership.deviceId} at index ${encryptionKeyIndex}`,
);
const keyBin = decodeBase64(encryptionKeyString);
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.encryptionKeys.has(mapKey)) {
this.encryptionKeys.set(mapKey, []);
}
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 ${mapKey} as it is older than existing known key`,
);
return;
}
if (keysEqual(existingKeyAtIndex.key, keyBin)) {
existingKeyAtIndex.timestamp = timestamp;
return;
}
}
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).
// It needs to happen here because we will send the key before the timeout has passed and sending
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
// it will use the wrong index (index - 1)!
this.latestGeneratedKeyIndex = encryptionKeyIndex;
}
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 ${mapKey} index ${encryptionKeyIndex}`);
this.onEncryptionKeysChanged(
keyBin,
encryptionKeyIndex,
membership,
this.rtcBackendIdentityFromMembershipParts(membership),
);
}, this.useKeyDelay);
this.setNewKeyTimeouts.add(useKeyTimeout);
} else {
this.onEncryptionKeysChanged(
keyBin,
encryptionKeyIndex,
membership,
this.rtcBackendIdentityFromMembershipParts(membership),
);
}
}
private onRotateKeyTimeout = (): void => {
if (!this.manageMediaKeys) return;
this.makeNewKeyTimeout = undefined;
this.logger.info("Making new sender key for key rotation");
const newKeyIndex = this.makeNewSenderKey(true);
// send immediately: if we're about to start sending with a new key, it's
// important we get it out to others as soon as we can.
void this.sendEncryptionKeysEvent(newKeyIndex);
};
}
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
}
+84 -139
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 - 2024 The Matrix.org Foundation C.I.C.
Copyright 2023 - 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,16 +25,16 @@ 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 { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import { type CallMembershipIdentityParts, type IEncryptionManager } from "./EncryptionManager.ts";
import { logDurationSync } from "../utils.ts";
import type {
Statistics,
RTCNotificationType,
Status,
IRTCNotificationContent,
ICallNotifyContent,
RTCCallIntent,
Transport,
SlotDescription,
} from "./types.ts";
import {
MembershipManagerEvent,
@@ -46,7 +46,7 @@ import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { type IContent, type MatrixEvent } from "../models/event.ts";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { computeSlotId } from "./utils.ts";
/**
* Events emitted by MatrixRTCSession
@@ -81,7 +81,6 @@ export type MatrixRTCSessionEventHandlerMap = {
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
[MatrixRTCSessionEvent.DidSendCallNotification]: (
notificationContentNew: { event_id: string } & IRTCNotificationContent,
notificationContentLegacy: { event_id: string } & ICallNotifyContent,
) => void;
};
@@ -98,21 +97,6 @@ export interface SessionConfig {
callIntent?: RTCCallIntent;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
id: string;
application: string;
}
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id] = slotId.split("#");
return { application, id };
}
export function slotDescriptionToId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
@@ -167,11 +151,6 @@ export interface MembershipConfig {
*/
networkErrorRetryMs?: number;
/**
* If true, use the new to-device transport for sending encryption keys.
*/
useExperimentalToDeviceTransport?: boolean;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
@@ -277,6 +256,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
public memberships: CallMembership[] = [];
/**
* Resolves when the session has calculated the initial membership of the session.
*/
public readonly initialMembershipCalculated: Promise<void>;
/**
* Does membership need to be recalculated? This is set to false upon
* recalculation.
*/
private membershipNeedsRecalculation = false;
/**
* The statistics for this session.
*/
@@ -317,7 +306,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* The slotId is the property that, per definition, groups memberships into one call.
*/
public get slotId(): string | undefined {
return slotDescriptionToId(this.slotDescription);
return computeSlotId(this.slotDescription);
}
/**
@@ -333,7 +322,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
// (prefer sticky events in case of a duplicate)
options: SessionMembershipsForSlotOpts = DEFAULT_SESSION_MEMBERSHIPS_FOR_SLOT_OPTS,
): Promise<CallMembership[]> {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const logger = rootLogger.getChild(
`[MatrixRTCSession ${room.roomId} ${slotDescription.application}#${slotDescription.id}]`,
);
const callMemberEvents = collectMembersEvents(room, options, logger);
const callMemberships = await computeBackendIdentityAndVerifyMemberEvents(
@@ -391,6 +382,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* @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`.
* @param calculateMembershipsOpts - Options to configure how memberships are calculated for this session.
*/
public constructor(
private readonly client: Pick<
@@ -414,21 +406,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
>,
private roomSubset: Pick<
Room,
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
| "getLiveTimeline"
| "roomId"
| "getVersion"
| "hasMembershipState"
| "on"
| "off"
| "_unstable_getStickyEvents"
>,
public readonly slotDescription: SlotDescription,
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this.logger = rootLogger.getChild(
`[MatrixRTCSession ${roomSubset.roomId} ${slotDescription.application}#${slotDescription.id}]`,
);
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.initialMembershipCalculated = this.ensureRecalculateSessionMembers();
this.setExpiryTimer();
}
/*
@@ -502,57 +500,28 @@ export class MatrixRTCSession extends TypedEventEmitter<
MembershipManagerEvent.DelayIdChanged,
]);
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
this.logger.info("Using experimental to-device transport for encryption keys");
this.logger.info("Using to-device with room fallback transport for encryption keys");
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
this.encryptionManager = new RTCEncryptionManager(
ownMembershipIdentity,
() => this.memberships,
transport,
this.statistics,
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
membership,
rtcBackendIdentity,
);
},
this.logger,
);
} else {
// TODO REMOVE ME!
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
ownMembershipIdentity,
() => this.memberships,
transport,
this.statistics,
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
membership,
rtcBackendIdentity,
);
},
);
}
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
this.encryptionManager = new RTCEncryptionManager(
ownMembershipIdentity,
() => this.memberships,
transport,
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
membership,
rtcBackendIdentity,
);
},
this.logger,
);
}
this.joinConfig = joinConfig;
@@ -622,13 +591,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
return oldestMembership?.getTransport(oldestMembership);
}
/**
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
* @deprecated does not work with m.rtc.member. Do not rely on it.
*/
public getActiveFocus(): Transport | undefined {
return this.getOldestMembership()?.getFocusActive();
}
public getOldestMembership(): CallMembership | undefined {
return this.memberships[0];
}
@@ -697,7 +659,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
if (soonestExpiry != undefined) {
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
this.expiryTimeout = setTimeout(() => void this.ensureRecalculateSessionMembers(), soonestExpiry);
}
}
@@ -713,20 +675,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
notificationType: RTCNotificationType,
callIntent?: RTCCallIntent,
): void {
const sendLegacyNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: ICallNotifyContent;
}> => {
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": notificationType === "notification" ? "notify" : notificationType,
"call_id": this.callId!,
};
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.CallNotify, content);
return { response, content };
};
const sendNewNotificationEvent = async (): Promise<{
const sendNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: IRTCNotificationContent;
}> => {
@@ -747,12 +696,11 @@ export class MatrixRTCSession extends TypedEventEmitter<
return { response, content };
};
void Promise.all([sendLegacyNotificationEvent(), sendNewNotificationEvent()])
.then(([legacy, newNotification]) => {
void sendNotificationEvent()
.then((notification) => {
// Join event_id and origin event content
const legacyResult = { ...legacy.response, ...legacy.content };
const newResult = { ...newNotification.response, ...newNotification.content };
this.emit(MatrixRTCSessionEvent.DidSendCallNotification, newResult, legacyResult);
const newResult = { ...notification.response, ...notification.content };
this.emit(MatrixRTCSessionEvent.DidSendCallNotification, newResult);
})
.catch(([errorLegacy, errorNew]) =>
this.logger.error("Failed to send call notification", errorLegacy, errorNew),
@@ -763,7 +711,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* Call this when the Matrix room members have changed.
*/
private readonly onRoomMemberUpdate = (): void => {
this.ensureRecalculateSessionMembers();
void this.ensureRecalculateSessionMembers();
};
/**
@@ -779,7 +727,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
(e) => e.getType() === EventType.RTCMembership,
)
) {
this.ensureRecalculateSessionMembers();
void this.ensureRecalculateSessionMembers();
}
};
@@ -793,22 +741,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
};
// helper variables to make sure we do not have parallel running recalculations.
private recalculateSessionMembersPromise: Promise<void> = Promise.resolve();
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;
/**
* Ensures that membership is recalculated when the state of the session may have changed.
* Also ensures that only one recalculation is made at a time.
* @returns A promise resolving when the state has been recalculated.
*/
private ensureRecalculateSessionMembers(): Promise<void> {
if (this.membershipNeedsRecalculation) {
// We have already requested recalcuation, don't attempt a new one.
return this.recalculateSessionMembersPromise;
}
this.membershipNeedsRecalculation = true;
// Chain the recalculation.
this.recalculateSessionMembersPromise = this.recalculateSessionMembersPromise
.finally()
.then(() => this.recalculateSessionMembers());
return this.recalculateSessionMembersPromise;
}
/**
@@ -819,6 +769,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This function should be called when the room members or call memberships might have changed.
*/
private readonly recalculateSessionMembers = async (): Promise<void> => {
// Clear the flag.
this.membershipNeedsRecalculation = false;
const oldMemberships = this.memberships;
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
@@ -882,20 +834,12 @@ async function computeBackendIdentityAndVerifyMemberEvents(
const content = memberEvent.getContent();
// Quick filter to avoid unneeded processing of invalid events or left events.
// A more thorough validation will be done later with CallMembership.membershipDataFromMatrixEvent.
if (!quickFilterNonRelevantContents(content, logger)) {
continue;
}
try {
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
const membership = new CallMembership(
memberEvent,
membershipData,
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
logger,
);
const membership = await CallMembership.parseFromEvent(memberEvent);
if (isValidMembership(membership, room, slotDescription, logger)) {
callMemberships.push(membership);
@@ -919,7 +863,8 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
// We have a MSC4143 event membership event with a proper joined content
return true;
} else if (eventKeysCount === 1 && "memberships" in content) {
logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`);
// Events used to have this format in the past, but are now deprecated.
// Given that state events ~cannot be deleted, there can be some remaining events in the room, just ignore them.
return false;
} else {
// Invalid or left content
@@ -933,9 +878,9 @@ function isValidMembership(
slotDescription: SlotDescription,
logger: Logger,
): boolean {
if (!deepCompare(membership.slotDescription, slotDescription)) {
if (membership.slotDescription.id !== slotDescription.id) {
logger.info(
`Ignoring membership of user ${membership.userId} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
`Ignoring membership of user ${membership.userId} for a different slot. Theirs: ${JSON.stringify(membership.slotDescription)}, Expected: ${JSON.stringify(slotDescription)}`,
);
return false;
}
+6 -4
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,8 +20,10 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
import { type SlotDescription } from "./types.ts";
import { computeSlotId } from "./utils.ts";
export enum MatrixRTCSessionManagerEvents {
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
@@ -56,10 +58,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
private readonly slotDescription: SlotDescription = { application: "m.call", id: "ROOM" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
this.logger = rootLogger.getChild(`[MatrixRTCSessionManager ${computeSlotId(slotDescription)}]`);
}
public start(): void {
+34 -21
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,19 +22,9 @@ import type { MatrixClient } from "../client.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts";
import {
type CallMembership,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
type SessionMembershipData,
} from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import {
type SlotDescription,
type MembershipConfig,
type SessionConfig,
slotDescriptionToId,
} from "./MatrixRTCSession.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION } from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status, type SlotDescription } from "./types.ts";
import { type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
@@ -43,6 +33,9 @@ import {
type IMembershipManager,
type MembershipManagerEventHandlerMap,
} from "./IMembershipManager.ts";
import { type RtcMembershipData, type SessionMembershipData } from "./membershipData/index.ts";
import { computeSlotId } from "./utils.ts";
import { isLivekitTransportConfig } from "./LivekitTransport.ts";
/* MembershipActionTypes:
On Join: (1)
@@ -584,7 +577,7 @@ export class MembershipManager
this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft);
}
private setAndEmitDelayId(delayId?: string): void {
private setAndEmitDelayId(delayId: string | undefined): void {
if (this.state.delayId === delayId) return;
this.state.delayId = delayId;
@@ -654,6 +647,7 @@ export class MembershipManager
._unstable_sendScheduledDelayedEvent(delayId)
.then(() => {
this.state.hasMemberStateEvent = false;
this.setAndEmitDelayId(undefined);
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
return { replace: [] };
@@ -772,7 +766,12 @@ export class MembershipManager
* 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}`;
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// Revert back to "" just for the state key (state keys are always legacy. we use sticky events for non legacy events)
const application = this.slotDescription.application;
const needsEmptyStringRoomFix = application === "m.call" && this.slotDescription.id === "ROOM";
const slotId = needsEmptyStringRoomFix ? "" : this.slotDescription.id;
const stateKey = `${localUserId}_${localDeviceId}_${application}${slotId}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
@@ -782,11 +781,14 @@ export class MembershipManager
/**
* Constructs our own membership
* @returns Only returns `SessionMembershipData`
*/
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const needsEmptyStringRoomFix =
this.slotDescription.application === "m.call" && this.slotDescription.id === "ROOM";
const focusObjects =
const focusObjects: Pick<SessionMembershipData, "foci_preferred" | "focus_active"> =
this.rtcTransport === undefined
? {
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
@@ -798,7 +800,9 @@ export class MembershipManager
};
return {
"application": this.slotDescription.application,
"call_id": this.slotDescription.id,
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// Revert back to "" just for the sending the event.
"call_id": needsEmptyStringRoomFix ? "" : this.slotDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
// DO NOT use this.memberId here since that is the state key (using application...)
@@ -1088,9 +1092,14 @@ export class StickyEventMembershipManager extends MembershipManager {
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
}
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
/**
*
* @returns Only returns `RtcMembershipData`
*/
protected makeMyMembership(): RtcMembershipData {
const ownMembership = this.ownMembership;
const livekitTransport = isLivekitTransportConfig(this.rtcTransport) ? this.rtcTransport : undefined;
const relationObject = ownMembership?.eventId
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
: {};
@@ -1099,8 +1108,12 @@ export class StickyEventMembershipManager extends MembershipManager {
type: this.slotDescription.application,
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
},
slot_id: slotDescriptionToId(this.slotDescription),
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
slot_id: computeSlotId(this.slotDescription),
// Make sure we do not add the alias to the transport.
// It is not needed in matrix2.0. The additional session information will be used to find the right alias on the sfu.
rtc_transports: livekitTransport
? [{ type: livekitTransport.type, livekit_service_url: livekitTransport.livekit_service_url }]
: [],
member: { device_id: this.deviceId, user_id: this.userId, id: this.memberId },
versions: [],
...relationObject,
+7 -10
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@ import {
type IEncryptionManager,
} from "./EncryptionManager.ts";
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
import { CallMembership } from "./CallMembership.ts";
import type { CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
@@ -30,9 +30,9 @@ import {
type InboundEncryptionSession,
type OutboundEncryptionSession,
type ParticipantDeviceInfo,
type Statistics,
} from "./types.ts";
import { OutdatedKeyFilter } from "./utils.ts";
import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
@@ -58,7 +58,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
* 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<
private readonly participantKeyRings = new Map<
EncryptionKeyMapKey,
Array<{
key: Uint8Array<ArrayBuffer>;
@@ -111,7 +111,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
private logger: Logger | undefined = undefined;
private rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
private readonly rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
/**
*
@@ -124,10 +124,9 @@ export class RTCEncryptionManager implements IEncryptionManager {
* @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes
*/
public constructor(
private ownMembership: CallMembershipIdentityParts,
private readonly ownMembership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBuffer>,
@@ -139,7 +138,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw;
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
}
private async getOwnRtcBackendIdentity(): Promise<string> {
@@ -296,7 +295,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
candidateInboundSession.keyIndex,
candidateInboundSession.membership,
);
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
} else {
this.logger?.info(
`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`,
@@ -410,7 +408,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
try {
this.logger?.trace(`Sending key...`);
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
this.statistics.counters.roomEventEncryptionKeysSent += 1;
outboundKey.sharedWith.push(...toDistributeTo);
this.logger?.trace(
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
-189
View File
@@ -1,189 +0,0 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { MatrixClient } from "../client.ts";
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { EventType } from "../@types/event.ts";
import { type MatrixError } from "../http-api/errors.ts";
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type MatrixEvent } from "../models/event.ts";
import { 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
{
private logger: Logger = rootLogger;
public setParentLogger(parentLogger: Logger): void {
this.logger = parentLogger.getChild(`[RoomKeyTransport]`);
}
public constructor(
private room: Pick<Room, "on" | "off" | "roomId">,
private client: Pick<
MatrixClient,
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
>,
private statistics: Statistics,
parentLogger?: Logger,
) {
super();
this.setParentLogger(parentLogger ?? rootLogger);
}
public start(): void {
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
}
public stop(): void {
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
}
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
if (!isRetry) {
this.logger.warn(
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
);
// retry after 1 second. After this we give up.
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
} else {
this.logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
}
return;
} else if (isRetry) {
this.logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
}
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
if (!this.room) {
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return Promise.resolve();
}
this.onEncryptionEvent(event);
}
/** implements {@link IKeyTransport#sendKey} */
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
// members not used in room transports as the keys are sent to all room members
const content: EncryptionKeysEventContent = {
keys: [
{
index: index,
key: keyBase64Encoded,
},
],
device_id: this.client.getDeviceId()!,
call_id: "",
sent_ts: Date.now(),
};
try {
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
} catch (error) {
this.logger.error("Failed to send call encryption keys", error);
const matrixError = error as MatrixError;
if (matrixError.event) {
// cancel the pending event: we'll just generate a new one with our latest
// keys when we resend
this.client.cancelPendingEvent(matrixError.event);
}
throw error;
}
}
public onEncryptionEvent(event: MatrixEvent): void {
const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>();
const deviceId = content["device_id"];
const callId = content["call_id"];
if (!userId) {
this.logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
return;
}
// We currently only handle callId = "" (which is the default for room scoped calls)
if (callId !== "") {
this.logger.warn(
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
);
return;
}
if (!Array.isArray(content.keys)) {
this.logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
return;
}
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
// We store our own sender key in the same set along with keys from others, so it's
// important we don't allow our own keys to be set by one of these events (apart from
// the fact that we don't need it anyway because we already know our own keys).
this.logger.info("Ignoring our own keys event");
return;
}
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
for (const key of content.keys) {
if (!key) {
this.logger.info("Ignoring false-y key in keys event");
continue;
}
const encryptionKey = key.key;
const encryptionKeyIndex = key.index;
if (
!encryptionKey ||
encryptionKeyIndex === undefined ||
encryptionKeyIndex === null ||
callId === undefined ||
callId === null ||
typeof deviceId !== "string" ||
typeof callId !== "string" ||
typeof encryptionKey !== "string" ||
typeof encryptionKeyIndex !== "number"
) {
this.logger.warn(
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
);
} else {
this.logger.debug(
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
);
this.emit(
KeyTransportEvents.ReceivedKeys,
// 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(),
);
}
}
}
}
+1
View File
@@ -19,5 +19,6 @@ export * from "./LivekitTransport.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
export { type SessionMembershipData, type RtcMembershipData } from "./membershipData/index.ts";
export { Status, parseCallNotificationContent, isMyMembership } from "./types.ts";
export { MembershipManagerEvent } from "./IMembershipManager.ts";
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Thrown when an event is not valid for use with MatrixRTC.
*/
export class MatrixRTCMembershipParseError extends AggregateError {
public constructor(
public readonly type: string,
errors: string[],
) {
super(errors, `Does not match ${type}:\n${errors.join("\n")}`);
}
}
@@ -1,5 +1,5 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,11 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "oidc-client-ts";
declare module "oidc-client-ts" {
interface OidcMetadata {
// Add the missing device_authorization_endpoint field to the OidcMetadata interface
device_authorization_endpoint?: string;
}
}
export { type SessionMembershipData, checkSessionsMembershipData } from "./session.ts";
export { type RtcMembershipData, computeRtcIdentityRaw, checkRtcMembershipData } from "./rtc.ts";
export { MatrixRTCMembershipParseError } from "./common.ts";
+157
View File
@@ -0,0 +1,157 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MXID_PATTERN } from "../../models/room-member.ts";
import type { IContent } from "../../models/event.ts";
import type { RelationType } from "../../types.ts";
import { type RtcSlotEventContent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
import { sha256 } from "../../digest.ts";
import { encodeUnpaddedBase64 } from "../../base64.ts";
import { slotIdToDescription } from "../utils.ts";
/**
* Represents the current form of MSC4143, which uses sticky events to store membership.
*/
export interface RtcMembershipData {
"slot_id": string;
"member": {
user_id: string;
device_id: string;
id: string;
};
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": RtcSlotEventContent["application"];
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
/**
* Validates that `data` matches the format expected by MSC4143.
* @param data The event content.
* @param sender The sender of the event.
* @returns true if `data` is valid RtcMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkRtcMembershipData = (data: IContent, sender: string): data is RtcMembershipData => {
const errors: string[] = [];
const prefix = " - ";
const expectedSlotPrefix = `${data?.application?.type}#`;
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else if (!data.slot_id.startsWith(expectedSlotPrefix)) {
errors.push(prefix + `slot_id must start with ${expectedSlotPrefix}`);
} else {
try {
slotIdToDescription(data.slot_id);
} catch (ex) {
errors.push(prefix + `slot_id was badly formed${ex instanceof Error ? `: ${ex.message}` : ""}`);
}
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") {
errors.push(prefix + "member.user_id must be string");
} else if (!MXID_PATTERN.test(data.member.user_id)) {
errors.push(prefix + "member.user_id must be a valid mxid");
}
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== sender) {
errors.push(prefix + "member.user_id must match the sender");
}
if (typeof data.member.device_id !== "string") {
errors.push(prefix + "member.device_id must be string");
}
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("RtcMembership", errors);
}
return true;
};
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
// canonical JSON serialization (Matrix canonical JSON for arrays)
const jsonStr = JSON.stringify([userId, deviceId, memberId]);
const hashBuffer = await sha256(jsonStr);
const hashedString = encodeUnpaddedBase64(hashBuffer);
return hashedString;
}
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IContent } from "../../matrix.ts";
import { type RTCCallIntent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
/**
* (MatrixRTC) session membership data.
* This represents the *OLD* form of MSC4143, which uses state events to store membership.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
* NOTE: This is still included for legacy reasons, but not consumed by the SDK.
*/
"focus_active": {
type: "livekit" | string;
focus_selection: "oldest_membership" | "multi_sfu" | string;
};
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: "m.room" | "m.user";
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The 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;
};
/**
* Validates that `data` matches the format expected by the legacy form of MSC4143.
* @param data The event content.
* @returns true if `data` is valid SessionMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => {
const prefix = " - ";
const errors: string[] = [];
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (typeof data.focus_active?.type !== "string") {
errors.push(prefix + "focus_active.type must be a string");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist)
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("SessionMembership", errors);
}
return true;
};
+27 -1
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -199,3 +199,29 @@ export interface Transport {
type: string;
[key: string]: unknown;
}
/**
* Event content for a `m.rtc.slot` state event.
*/
export interface RtcSlotEventContent<T extends string = string> {
application: {
type: T;
// other application specific keys
[key: string]: unknown;
};
slot_id: string;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
/**
* The application type. e.g. "m.call".
*/
application: string;
/**
* The application-specific slot ID. e.g. "ROOM".
*/
id: string;
}
+24 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import { type InboundEncryptionSession, type EncryptionKeyMapKey } from "./types.ts";
import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
@@ -47,3 +47,25 @@ export class OutdatedKeyFilter {
return false;
}
}
/**
* Converts a slot ID into it's component application and ID portions.
* @param slotId e.g. `m.call#call_id`
* @throws If the format of `slotId` is invalid.
*/
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id, ...unexpectedAdditionalValues] = slotId.split("#");
if (unexpectedAdditionalValues.length) {
throw Error(
"MatrixRTC Slot IDs *must* only contain two components seperated by one '#'. Additional '#' characters detected.",
);
}
return { application, id };
}
/**
* Converts a SlotDescription into it's slot ID format.
*/
export function computeSlotId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
-1
View File
@@ -38,7 +38,6 @@ export type BeaconEventHandlerMap = {
[BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
[BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
};
export const isTimestampInDuration = (startTimestamp: number, durationMs: number, timestamp: number): boolean =>
+3 -1
View File
@@ -76,6 +76,8 @@ export interface IUnsigned {
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
"msc4354_sticky_duration_ttl_ms"?: number;
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
"membership"?: Membership;
"io.element.msc4115.membership"?: Membership;
}
export interface IThreadBundledRelationship {
@@ -786,7 +788,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*/
public getMembershipAtEvent(): Membership | string | undefined {
const unsigned = this.getUnsigned();
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership>(unsigned);
}
/**
+38 -16
View File
@@ -53,6 +53,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
private targetEvent: MatrixEvent | null = null;
private creationEmitted = false;
private replacementUpdateId = 0;
private readonly client: MatrixClient;
/**
@@ -106,9 +107,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@@ -132,9 +132,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
this.emit(RelationsEvent.Remove, event);
@@ -243,9 +242,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key
this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@@ -343,18 +341,42 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
}
this.targetEvent = event;
if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly
if (replacement) {
this.targetEvent.makeReplaced(replacement);
}
if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
this.maybeEmitCreated();
}
/**
* Updates the target event with the latest replacement.
*
* Multiple replacement updates can be triggered concurrently (for example
* while edits are still being decrypted). A monotonic update counter guards
* against older async resolutions overriding newer replacement selections.
*/
private async updateTargetEventReplacement(): Promise<void> {
if (!this.targetEvent || this.targetEvent.isState()) {
return;
}
const targetEvent = this.targetEvent;
const updateId = ++this.replacementUpdateId;
const lastReplacement = await this.getLastReplacement();
// If a newer update started while we were awaiting, discard this stale result.
if (updateId !== this.replacementUpdateId || this.targetEvent !== targetEvent) {
return;
}
// Avoid emitting Event.replaced when there is no replacement and none currently set.
if (!lastReplacement && !targetEvent.replacingEvent()) {
return;
}
targetEvent.makeReplaced(lastReplacement ?? undefined);
}
private maybeEmitCreated(): void {
if (this.creationEmitted) {
return;
+36
View File
@@ -227,6 +227,42 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
}
}
/**
* Recalculate the disambiguation flag for this member based on current room state.
* This should be called when another member's display name changes and may affect
* whether this member needs disambiguation.
*
* @param roomState - The current room state to use for disambiguation check
* @returns true if the member's name changed as a result of the disambiguation update
*
* @remarks
* Fires {@link RoomMemberEvent.Name}
*/
public recalculateDisambiguatedName(roomState: RoomState): boolean {
if (!this.events.member) {
return false;
}
const displayName = this.events.member.getDirectionalContent().displayname ?? "";
const newDisambiguate = shouldDisambiguate(this.userId, displayName, roomState);
if (newDisambiguate === this.disambiguate) {
return false;
}
this.disambiguate = newDisambiguate;
const oldName = this.name;
this.name = calculateDisplayName(this.userId, displayName, this.disambiguate);
if (oldName !== this.name) {
this.updateModifiedTime();
this.emit(RoomMemberEvent.Name, this.events.member, this, oldName);
return true;
}
return false;
}
/**
* Update this room member's power level event. Will fire
* "RoomMember.powerLevel" if the new power level is different
+49 -1
View File
@@ -438,6 +438,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.updateModifiedTime();
// update the core event dict
// Track display names that change so we can recalculate disambiguation
const affectedDisplayNames = new Set<string>();
// Track userIds whose membership events we process so we don't emit duplicate events
const processedMemberUserIds = new Set<string>();
stateEvents.forEach((event) => {
if (event.getRoomId() !== this.roomId || !event.isState()) return;
@@ -448,7 +453,22 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
const lastStateEvent = this.getStateEventMatching(event);
this.setStateEvent(event);
if (event.getType() === EventType.RoomMember) {
this.updateDisplayNameCache(event.getStateKey()!, event.getContent().displayname ?? "");
const userId = event.getStateKey()!;
processedMemberUserIds.add(userId);
const newDisplayName = event.getContent().displayname ?? "";
const oldDisplayName = this.userIdsToDisplayNames[userId];
// Track both old and new display names for disambiguation recalculation
if (oldDisplayName) {
const strippedOld = removeHiddenChars(oldDisplayName);
if (strippedOld) affectedDisplayNames.add(strippedOld);
}
if (newDisplayName) {
const strippedNew = removeHiddenChars(newDisplayName);
if (strippedNew) affectedDisplayNames.add(strippedNew);
}
this.updateDisplayNameCache(userId, newDisplayName);
this.updateThirdPartyTokenCache(event);
}
this.emit(RoomStateEvent.Events, event, this, lastStateEvent);
@@ -514,6 +534,33 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
}
});
// Recalculate disambiguation for all members whose display names were affected.
// This ensures that when a user changes their name to match (or stop matching)
// another user, all affected users' disambiguation flags are updated correctly.
if (affectedDisplayNames.size > 0) {
// Collect all affected user IDs first to avoid duplicate processing
const affectedUserIds = new Set<string>();
for (const displayName of affectedDisplayNames) {
const userIds = this.displayNameToUserIds.get(displayName) ?? [];
userIds.forEach((id) => affectedUserIds.add(id));
}
// Process each affected member once, excluding those whose membership
// events were already processed (they already got their events emitted)
for (const userId of affectedUserIds) {
if (processedMemberUserIds.has(userId)) {
continue;
}
const member = this.members[userId];
if (member?.events.member) {
const nameChanged = member.recalculateDisambiguatedName(this);
if (nameChanged) {
this.emit(RoomStateEvent.Members, member.events.member, this, member);
}
}
}
}
this.emit(RoomStateEvent.Update, this);
}
@@ -1110,6 +1157,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
private updateDisplayNameCache(userId: string, displayName: string): void {
const oldName = this.userIdsToDisplayNames[userId];
delete this.userIdsToDisplayNames[userId];
if (oldName) {
// Remove the old name from the cache.
+25 -6
View File
@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import {
type IdTokenClaims,
Log,
OidcClient,
type SigninRequestCreateArgs,
SigninResponse,
SigninState,
WebStorageStateStore,
} from "oidc-client-ts";
import { logger } from "../logger.ts";
import { secureRandomString } from "../randomstring.ts";
@@ -127,6 +135,8 @@ export const generateAuthorizationUrl = async (
* @param urlState - value to append to the opaque state identifier to uniquely identify the callback
* @param loginHint - value to send as the `login_hint` to the OP, giving a hint about the login identifier the user might use to log in.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
* @param responseMode - value to send as the `response_mode` to the OP, selecting how auth is passed back during redirect.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
* @returns a Promise with the url as a string
*/
export const generateOidcAuthorizationUrl = async ({
@@ -139,6 +149,7 @@ export const generateOidcAuthorizationUrl = async ({
prompt,
urlState,
loginHint,
responseMode = "query",
}: {
clientId: string;
metadata: ValidatedAuthMetadata;
@@ -149,6 +160,7 @@ export const generateOidcAuthorizationUrl = async ({
prompt?: string;
urlState?: string;
loginHint?: string;
responseMode?: SigninRequestCreateArgs["response_mode"];
}): Promise<string> => {
const scope = generateScope();
const oidcClient = new OidcClient({
@@ -156,7 +168,7 @@ export const generateOidcAuthorizationUrl = async ({
client_id: clientId,
redirect_uri: redirectUri,
authority: metadata.issuer,
response_mode: "query",
response_mode: responseMode,
response_type: "code",
scope,
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
@@ -200,7 +212,8 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
* request to the Token Endpoint, to obtain the access token, refresh token, etc.
*
* @param code - authorization code as returned by OP during authorization
* @param storedAuthorizationParams - stored params from start of oidc login flow
* @param state - authorization state param as returned by OP during authorization
* @param responseMode - the response mode used for authentication
* @returns valid bearer token response
* @throws An `Error` with `message` set to an entry in {@link OidcError},
* when the request fails, or the returned token response is invalid.
@@ -208,6 +221,7 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
export const completeAuthorizationCodeGrant = async (
code: string,
state: string,
responseMode: SigninRequestCreateArgs["response_mode"] = "query",
): Promise<{
oidcClientSettings: { clientId: string; issuer: string };
tokenResponse: BearerTokenResponse;
@@ -221,13 +235,18 @@ export const completeAuthorizationCodeGrant = async (
* so that oidc-client can parse it
*/
const reconstructedUrl = new URL(window.location.origin);
reconstructedUrl.searchParams.append("code", code);
reconstructedUrl.searchParams.append("state", state);
const params = new URLSearchParams({ code, state });
if (responseMode === "query") {
reconstructedUrl.search = params.toString();
} else {
reconstructedUrl.hash = `#${params.toString()}`;
}
// set oidc-client to use our logger
Log.setLogger(logger);
try {
const response = new SigninResponse(reconstructedUrl.searchParams);
const response = new SigninResponse(params);
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
+1 -1
View File
@@ -62,6 +62,6 @@ export const validateAuthMetadataAndKeys = async (authMetadata: unknown): Promis
return {
...validatedIssuerConfig,
signingKeys: await metadataService.getSigningKeys(),
signingKeys: validatedIssuerConfig.jwks_uri ? await metadataService.getSigningKeys() : null,
};
};
+29 -2
View File
@@ -52,7 +52,34 @@ interface OidcRegistrationRequestBody {
application_type: "web" | "native";
}
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
/**
* The OAuth 2.0 grant types that are defined for Matrix in https://spec.matrix.org/v1.17/client-server-api/#grant-types
*/
export enum OAuthGrantType {
/**
* See https://spec.matrix.org/v1.17/client-server-api/#authorization-code-grant
*/
AuthorizationCode = "authorization_code",
/**
* https://spec.matrix.org/v1.17/client-server-api/#refresh-token-grant
*/
RefreshToken = "refresh_token",
/**
* The OAuth 2.0 Device Authorization Grant type identifier as per
* https://www.rfc-editor.org/rfc/rfc8628.html#section-7.2 from
* [MSC4341](https://github.com/matrix-org/matrix-spec-proposals/pull/4341).
*
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
DeviceAuthorization = "urn:ietf:params:oauth:grant-type:device_code",
}
/**
* The name "scope" is a misnomer here as it is actually a "grant type".
*
* @deprecated use `OAuthGrantType.DeviceAuthorization` instead
*/
export const DEVICE_CODE_SCOPE: string = OAuthGrantType.DeviceAuthorization;
// Check that URIs have a common base, as per the MSC2966 definition
const urlHasCommonBase = (base: URL, urlStr?: string): boolean => {
@@ -79,7 +106,7 @@ export const registerOidcClient = async (
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
const grantTypes: NonEmptyArray<string> = ["authorization_code", "refresh_token"];
const grantTypes: NonEmptyArray<string> = [OAuthGrantType.AuthorizationCode, OAuthGrantType.RefreshToken];
if (grantTypes.some((scope) => !delegatedAuthConfig.grant_types_supported.includes(scope))) {
throw new Error(OidcError.DynamicRegistrationNotSupported);
}

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