diff --git a/.cargo/config.toml b/.cargo/config.toml index 307bcfe43..18f6fb282 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [build] rustflags = ["--cfg", "tokio_unstable"] diff --git a/.codecov.yml b/.codecov.yml index 54f4aaf72..a946b3cea 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + comment: false flag_management: diff --git a/.config/nextest.toml b/.config/nextest.toml index 351fb92d7..7ed06faa5 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,2 +1,7 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [profile.default] retries = 1 diff --git a/.dockerignore b/.dockerignore index e016faae8..01fcc861a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + target/ crates/*/target crates/*/node_modules diff --git a/.editorconfig b/.editorconfig index 23e144851..ccbe6ed4b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + root = true [*] diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 7e11055a9..000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.wasm binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..222697181 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @element-hq/mas-maintainers diff --git a/.github/actions/build-frontend/action.yml b/.github/actions/build-frontend/action.yml index a7a1fe2c5..417f1f9ae 100644 --- a/.github/actions/build-frontend/action.yml +++ b/.github/actions/build-frontend/action.yml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Build the frontend assets description: Installs Node.js and builds the frontend assets from the frontend directory @@ -7,7 +12,7 @@ runs: - name: Install Node uses: actions/setup-node@v4.2.0 with: - node-version: '22' + node-version: "22" - name: Install dependencies run: npm ci diff --git a/.github/actions/build-policies/action.yml b/.github/actions/build-policies/action.yml index 274aa8134..e1dc28547 100644 --- a/.github/actions/build-policies/action.yml +++ b/.github/actions/build-policies/action.yml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Build the Open Policy Agent policies description: Installs OPA and builds the policies @@ -7,7 +12,8 @@ runs: - name: Install Open Policy Agent uses: open-policy-agent/setup-opa@v2.2.0 with: - version: 1.1.0 + # Keep in sync with the Dockerfile and policies/Makefile + version: 1.8.0 - name: Build the policies run: make diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a98d07e09..8b67a1414 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + version: 2 updates: - package-ecosystem: "cargo" diff --git a/.github/release.yml b/.github/release.yml index 8b25ed964..3633ae68a 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + changelog: categories: - title: Bug Fixes diff --git a/.github/scripts/.gitignore b/.github/scripts/.gitignore index 504afef81..efa4841d7 100644 --- a/.github/scripts/.gitignore +++ b/.github/scripts/.gitignore @@ -1,2 +1,7 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + node_modules/ package-lock.json diff --git a/.github/scripts/cleanup-pr.cjs b/.github/scripts/cleanup-pr.cjs index e5189cec8..43db85727 100644 --- a/.github/scripts/cleanup-pr.cjs +++ b/.github/scripts/cleanup-pr.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/scripts/commit-and-tag.cjs b/.github/scripts/commit-and-tag.cjs index b95782541..086e1b83e 100644 --- a/.github/scripts/commit-and-tag.cjs +++ b/.github/scripts/commit-and-tag.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/scripts/create-release-branch.cjs b/.github/scripts/create-release-branch.cjs index c7c0018a3..2508765ff 100644 --- a/.github/scripts/create-release-branch.cjs +++ b/.github/scripts/create-release-branch.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/scripts/create-version-tag.cjs b/.github/scripts/create-version-tag.cjs index 97536c5ff..47e00ecb1 100644 --- a/.github/scripts/create-version-tag.cjs +++ b/.github/scripts/create-version-tag.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/scripts/merge-back.cjs b/.github/scripts/merge-back.cjs index 30b08329d..d3948398e 100644 --- a/.github/scripts/merge-back.cjs +++ b/.github/scripts/merge-back.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/scripts/update-release-branch.cjs b/.github/scripts/update-release-branch.cjs index 0a94aa217..78dbb4686 100644 --- a/.github/scripts/update-release-branch.cjs +++ b/.github/scripts/update-release-branch.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/scripts/update-unstable-tag.cjs b/.github/scripts/update-unstable-tag.cjs index 1958adcec..765e85d6a 100644 --- a/.github/scripts/update-unstable-tag.cjs +++ b/.github/scripts/update-unstable-tag.cjs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @ts-check diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7ac2669e5..0c719faed 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Build on: @@ -41,7 +46,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 with: # Need a full clone so that `git describe` reports the right version fetch-depth: 0 @@ -62,7 +67,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - uses: ./.github/actions/build-frontend - uses: ./.github/actions/build-policies @@ -79,7 +84,7 @@ jobs: chmod -R u=rwX,go=rX assets-dist/ - name: Upload assets - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v5.0.0 with: name: assets path: assets-dist @@ -107,7 +112,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -138,7 +143,7 @@ jobs: -p mas-cli - name: Upload binary artifact - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v5.0.0 with: name: binary-${{ matrix.target }} path: target/${{ matrix.target }}/release/mas-cli @@ -157,19 +162,19 @@ jobs: steps: - name: Download assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: assets path: assets-dist - name: Download binary x86_64 - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: binary-x86_64-unknown-linux-gnu path: binary-x86_64 - name: Download binary aarch64 - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: binary-aarch64-unknown-linux-gnu path: binary-aarch64 @@ -187,13 +192,13 @@ jobs: done - name: Upload aarch64 archive - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v5.0.0 with: name: mas-cli-aarch64-linux path: mas-cli-aarch64-linux.tar.gz - name: Upload x86_64 archive - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v5.0.0 with: name: mas-cli-x86_64-linux path: mas-cli-x86_64-linux.tar.gz @@ -221,7 +226,7 @@ jobs: steps: - name: Docker meta id: meta - uses: docker/metadata-action@v5.7.0 + uses: docker/metadata-action@v5.9.0 with: images: "${{ env.IMAGE }}" bake-target: docker-metadata-action @@ -237,7 +242,7 @@ jobs: - name: Docker meta (debug variant) id: meta-debug - uses: docker/metadata-action@v5.7.0 + uses: docker/metadata-action@v5.9.0 with: images: "${{ env.IMAGE }}" bake-target: docker-metadata-action-debug @@ -253,17 +258,17 @@ jobs: type=sha - name: Setup Cosign - uses: sigstore/cosign-installer@v3.8.2 + uses: sigstore/cosign-installer@v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.10.0 + uses: docker/setup-buildx-action@v3.11.1 with: buildkitd-config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -271,7 +276,7 @@ jobs: - name: Build and push id: bake - uses: docker/bake-action@v6.8.0 + uses: docker/bake-action@v6.9.0 with: files: | ./docker-bake.hcl @@ -315,14 +320,14 @@ jobs: - build-image steps: - name: Download the artifacts from the previous job - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: pattern: mas-cli-* path: artifacts merge-multiple: true - name: Prepare a release - uses: softprops/action-gh-release@v2.3.2 + uses: softprops/action-gh-release@v2.4.1 with: generate_release_notes: true body: | @@ -371,27 +376,27 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 with: sparse-checkout: | .github/scripts - name: Download the artifacts from the previous job - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: pattern: mas-cli-* path: artifacts merge-multiple: true - name: Update unstable git tag - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const script = require('./.github/scripts/update-unstable-tag.cjs'); await script({ core, github, context }); - name: Update unstable release - uses: softprops/action-gh-release@v2.3.2 + uses: softprops/action-gh-release@v2.4.1 with: name: "Unstable build" tag_name: unstable @@ -449,13 +454,13 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 with: sparse-checkout: | .github/scripts - name: Remove label and comment - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 env: BUILD_IMAGE_MANIFEST: ${{ needs.build-image.outputs.metadata }} with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b3f438f5f..4331499a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: CI on: @@ -29,14 +34,15 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - uses: ./.github/actions/build-policies - name: Setup Regal uses: StyraInc/setup-regal@v1 with: - version: 0.29.2 + # Keep in sync with policies/Makefile + version: 0.36.1 - name: Lint policies working-directory: ./policies @@ -55,10 +61,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 22 @@ -79,10 +85,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 22 @@ -103,10 +109,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 20 @@ -127,7 +133,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@nightly @@ -150,10 +156,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Run `cargo-deny` - uses: EmbarkStudios/cargo-deny-action@v2.0.11 + uses: EmbarkStudios/cargo-deny-action@v2.0.13 with: rust-version: stable @@ -166,7 +172,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain run: | @@ -207,10 +213,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@1.89.0 with: components: clippy @@ -232,7 +238,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -251,7 +257,7 @@ jobs: SQLX_OFFLINE: "1" - name: Upload archive to workflow - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v5.0.0 with: name: nextest-archive path: nextest-archive.tar.zst @@ -285,7 +291,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -299,7 +305,7 @@ jobs: - uses: ./.github/actions/build-policies - name: Download archive - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: nextest-archive diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 12f2419b3..3046f45cc 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Coverage on: @@ -24,7 +29,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - uses: ./.github/actions/build-policies @@ -33,7 +38,7 @@ jobs: run: make coverage - name: Upload to codecov.io - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: policies/coverage.json @@ -49,7 +54,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - uses: ./.github/actions/build-frontend env: @@ -60,7 +65,7 @@ jobs: run: npm run coverage - name: Upload to codecov.io - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} directory: frontend/coverage/ @@ -94,7 +99,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -127,7 +132,7 @@ jobs: grcov . --binary-path ./target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov - name: Upload to codecov.io - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: target/coverage/*.lcov diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 811a35b9b..260bc7e80 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,10 +1,15 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Build and deploy the documentation on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -20,7 +25,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -34,7 +39,7 @@ jobs: tool: mdbook - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 22 @@ -48,7 +53,7 @@ jobs: done - name: Upload GitHub Pages artifacts - uses: actions/upload-pages-artifact@v3.0.1 + uses: actions/upload-pages-artifact@v4.0.0 with: path: target/book/ diff --git a/.github/workflows/merge-back.yaml b/.github/workflows/merge-back.yaml index 04884e2be..d28d68c20 100644 --- a/.github/workflows/merge-back.yaml +++ b/.github/workflows/merge-back.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Merge back a reference to main on: workflow_call: @@ -19,13 +24,13 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: sparse-checkout: | .github/scripts - name: Push branch and open a PR - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 env: SHA: ${{ inputs.sha }} with: diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index 8575d5975..0da9a1e1b 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Create a new release branch on: workflow_dispatch: @@ -29,7 +34,7 @@ jobs: run: exit 1 - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -56,10 +61,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 22 @@ -101,13 +106,13 @@ jobs: needs: [tag, compute-version, localazy] steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 with: sparse-checkout: | .github/scripts - name: Create a new release branch - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 env: BRANCH: release/v${{ needs.compute-version.outputs.short }} SHA: ${{ needs.tag.outputs.sha }} diff --git a/.github/workflows/release-bump.yaml b/.github/workflows/release-bump.yaml index 841893d50..46251a410 100644 --- a/.github/workflows/release-bump.yaml +++ b/.github/workflows/release-bump.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Bump the version on a release branch on: workflow_dispatch: @@ -28,7 +33,7 @@ jobs: run: exit 1 - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -71,13 +76,13 @@ jobs: needs: [tag, compute-version] steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 with: sparse-checkout: | .github/scripts - name: Update the release branch - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 env: BRANCH: "${{ github.ref_name }}" SHA: ${{ needs.tag.outputs.sha }} diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 02555f5e1..ec3ea290d 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Tag a new version on: workflow_call: @@ -25,7 +30,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -41,7 +46,7 @@ jobs: run: cargo metadata --format-version 1 - name: Commit and tag using the GitHub API - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 id: commit env: VERSION: ${{ inputs.version }} @@ -53,7 +58,7 @@ jobs: return await script({ core, github, context }); - name: Update the refs - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 env: VERSION: ${{ inputs.version }} TAG_SHA: ${{ fromJSON(steps.commit.outputs.result).tag }} diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index d2380c0df..2ebd790e5 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Download translation files from Localazy on: workflow_dispatch: @@ -14,10 +19,10 @@ jobs: run: exit 1 - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 22 diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 2c5a27c3e..38f552f40 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + name: Upload translation files to Localazy on: push: @@ -13,10 +18,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 - name: Install Node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.0.0 with: node-version: 22 diff --git a/.gitignore b/.gitignore index 1046cc35a..d98402c23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + # Rust -target/ +target # Editors .idea diff --git a/.rustfmt.toml b/.rustfmt.toml index 0e4510bbb..72a97f569 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + max_width = 100 comment_width = 80 wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock index 36b3ea8c4..43b6c8910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,9 +23,9 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -64,14 +64,14 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "aide" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d34f0f6ce85b460bf2f9e7eea6612f217ba700ae14e9e476805d2413480f64b" +checksum = "6966317188cdfe54c58c0900a195d021294afb3ece9b7073d09e4018dbb1e3a2" dependencies = [ "aide-macros", "axum", @@ -95,12 +95,12 @@ dependencies = [ "bytes", "cfg-if", "http", - "indexmap 2.9.0", - "schemars", + "indexmap 2.11.4", + "schemars 0.9.0", "serde", "serde_json", "serde_qs", - "thiserror 2.0.12", + "thiserror 2.0.17", "tower-layer", "tower-service", "tracing", @@ -124,12 +124,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -141,9 +135,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -156,50 +150,50 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arc-swap" @@ -260,9 +254,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -272,14 +266,15 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", "fastrand", "futures-lite", + "pin-project-lite", "slab", ] @@ -289,7 +284,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-io", "async-lock", @@ -316,7 +311,7 @@ dependencies = [ "futures-timer", "futures-util", "http", - "indexmap 2.9.0", + "indexmap 2.11.4", "mime", "multer", "num-traits", @@ -368,18 +363,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de" dependencies = [ "bytes", - "indexmap 2.9.0", + "indexmap 2.11.4", "serde", "serde_json", ] [[package]] name = "async-io" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", @@ -388,45 +383,43 @@ dependencies = [ "polling", "rustix", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] [[package]] name = "async-process" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-lite", "rustix", - "tracing", ] [[package]] name = "async-signal" -version = "0.2.10" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -437,14 +430,14 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "async-std" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ "async-channel 1.9.0", "async-global-executor", @@ -497,9 +490,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -517,9 +510,9 @@ dependencies = [ [[package]] name = "atomic" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ "bytemuck", ] @@ -532,15 +525,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b756939cb2f8dc900aa6dcd505e6e2428e9cae7ff7b028c49e3946efa70878" +checksum = "94b8ff6c09cd57b16da53641caa860168b88c172a5ee163b0288d3d6eea12786" dependencies = [ "aws-lc-sys", "zeroize", @@ -548,9 +541,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.28.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f7720b74ed28ca77f90769a71fd8c637a0137f6fae4ae947e1050229cff57f" +checksum = "0e44d16778acaf6a9ec9899b92cebd65580b83f685446bf2e1f5d3d732f99dcd" dependencies = [ "bindgen", "cc", @@ -561,9 +554,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", "bytes", @@ -580,8 +573,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -595,9 +587,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", @@ -606,7 +598,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -615,14 +606,15 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" dependencies = [ "axum", "axum-core", "bytes", "cookie", + "form_urlencoded", "futures-util", "headers", "http", @@ -631,10 +623,12 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "serde", - "tower", + "serde_core", + "serde_html_form", + "serde_path_to_error", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -650,9 +644,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -683,38 +677,35 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bcrypt" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f" +checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" dependencies = [ "base64", "blowfish", - "getrandom 0.3.2", + "getrandom 0.3.3", "subtle", "zeroize", ] [[package]] name = "bindgen" -version = "0.69.5" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash", "shlex", "syn", - "which", ] [[package]] @@ -734,9 +725,9 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] @@ -770,11 +761,11 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-task", "futures-io", "futures-lite", @@ -793,18 +784,18 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" dependencies = [ "allocator-api2", ] [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "byteorder" @@ -833,18 +824,18 @@ dependencies = [ [[package]] name = "camino" -version = "1.1.10" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "castaway" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] @@ -860,10 +851,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.18" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -886,15 +878,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chacha20" @@ -922,40 +908,28 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "chrono-tz" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "chrono-tz-build", "phf", ] -[[package]] -name = "chrono-tz-build" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402" -dependencies = [ - "parse-zoneinfo", - "phf_codegen", -] - [[package]] name = "chronoutil" version = "0.2.7" @@ -999,9 +973,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" dependencies = [ "clap_builder", "clap_derive", @@ -1009,9 +983,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" dependencies = [ "anstream", "anstyle", @@ -1021,9 +995,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1033,9 +1007,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "cmake" @@ -1048,15 +1022,18 @@ dependencies = [ [[package]] name = "cobs" -version = "0.2.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" @@ -1100,7 +1077,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.0", + "unicode-width 0.2.1", "windows-sys 0.59.0", ] @@ -1138,9 +1115,9 @@ dependencies = [ [[package]] name = "cookie_store" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" dependencies = [ "cookie", "document-features", @@ -1155,9 +1132,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -1189,33 +1166,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4b56ebe316895d3fa37775d0a87b0c889cc933f5c8b253dbcc7c7bcb7fe7e4" +checksum = "0ae7b60ec3fd7162427d3b3801520a1908bef7c035b52983cd3ca11b8e7deb51" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95cabbc01dfbd7dcd6c329ca44f0212910309c221797ac736a67a5bc8857fe1b" +checksum = "6511c200fed36452697b4b6b161eae57d917a2044e6333b1c1389ed63ccadeee" +dependencies = [ + "cranelift-srcgen", +] [[package]] name = "cranelift-bforest" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ffe46df300a45f1dc6f609dc808ce963f0e3a2e971682c479a2d13e3b9b8ef" +checksum = "5f7086a645aa58bae979312f64e3029ac760ac1b577f5cd2417844842a2ca07f" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b265bed7c51e1921fdae6419791d31af77d33662ee56d7b0fa0704dc8d231cab" +checksum = "5225b4dec45f3f3dbf383f12560fac5ce8d780f399893607e21406e12e77f491" dependencies = [ "serde", "serde_derive", @@ -1223,9 +1203,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e606230a7e3a6897d603761baee0d19f88d077f17b996bb5089488a29ae96e41" +checksum = "858fb3331e53492a95979378d6df5208dd1d0d315f19c052be8115f4efc888e0" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1237,47 +1217,49 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.15.2", + "hashbrown 0.15.5", "log", "pulley-interpreter", "regalloc2", - "rustc-hash 2.1.1", + "rustc-hash", "serde", "smallvec", "target-lexicon", + "wasmtime-internal-math", ] [[package]] name = "cranelift-codegen-meta" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a63bffafc23bc60969ad528e138788495999d935f0adcfd6543cb151ca8637d" +checksum = "456715b9d5f12398f156d5081096e7b5d039f01b9ecc49790a011c8e43e65b5f" dependencies = [ - "cranelift-assembler-x64", + "cranelift-assembler-x64-meta", "cranelift-codegen-shared", + "cranelift-srcgen", "pulley-interpreter", ] [[package]] name = "cranelift-codegen-shared" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af50281b67324b58e843170a6a5943cf6d387c06f7eeacc9f5696e4ab7ae7d7e" +checksum = "0306041099499833f167a0ddb707e1e54100f1a84eab5631bc3dad249708f482" [[package]] name = "cranelift-control" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c20c1b38d1abfbcebb0032e497e71156c0e3b8dcb3f0a92b9863b7bcaec290c" +checksum = "1672945e1f9afc2297f49c92623f5eabc64398e2cb0d824f8f72a2db2df5af23" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2c67d95507c51b4a1ff3f3555fe4bfec36b9e13c1b684ccc602736f5d5f4a2" +checksum = "aa3cd55eb5f3825b9ae5de1530887907360a6334caccdc124c52f6d75246c98a" dependencies = [ "cranelift-bitset", "serde", @@ -1286,9 +1268,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e002691cc69c38b54fc7ec93e5be5b744f627d027031d991cc845d1d512d0ce" +checksum = "781f9905f8139b8de22987b66b522b416fe63eb76d823f0b3a8c02c8fd9500c7" dependencies = [ "cranelift-codegen", "log", @@ -1298,21 +1280,27 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93588ed1796cbcb0e2ad160403509e2c5d330d80dd6e0014ac6774c7ebac496" +checksum = "a05337a2b02c3df00b4dd9a263a027a07b3dff49f61f7da3b5d195c21eaa633d" [[package]] name = "cranelift-native" -version = "0.118.0" +version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5b09bdd6407bf5d89661b80cf926ce731c9e8cc184bf49102267a2369a8358e" +checksum = "2eee7a496dd66380082c9c5b6f2d5fa149cec0ec383feec5caf079ca2b3671c2" dependencies = [ "cranelift-codegen", "libc", "target-lexicon", ] +[[package]] +name = "cranelift-srcgen" +version = "0.122.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b530783809a55cb68d070e0de60cfbb3db0dc94c8850dd5725411422bedcf6bb" + [[package]] name = "crc" version = "3.3.0" @@ -1330,9 +1318,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1345,7 +1333,7 @@ checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" dependencies = [ "chrono", "once_cell", - "winnow", + "winnow 0.6.26", ] [[package]] @@ -1495,12 +1483,12 @@ dependencies = [ [[package]] name = "deadpool" -version = "0.10.0" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "async-trait", "deadpool-runtime", + "lazy_static", "num_cpus", "tokio", ] @@ -1534,9 +1522,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", "serde", @@ -1632,20 +1620,20 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "duration-str" -version = "0.12.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ad6b66883f70e2f38f1ee99e3797b9d7e7b7fb051ed2e23e027c81753056c8" +checksum = "eb333721800c025e363e902b293040778f8ac79913db4f013abf1f1d7d382fd7" dependencies = [ "rust_decimal", - "thiserror 2.0.12", - "winnow", + "thiserror 2.0.17", + "winnow 0.7.13", ] [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -1693,9 +1681,9 @@ dependencies = [ [[package]] name = "email-encoding" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20b9cde6a71f9f758440470f3de16db6c09a02c443ce66850d87f5410548fb8e" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ "base64", "memchr", @@ -1742,12 +1730,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -1769,9 +1757,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1784,7 +1772,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "pin-project-lite", ] @@ -1801,8 +1789,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1837,6 +1825,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fixed_decimal" version = "0.5.6" @@ -1873,9 +1867,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1947,9 +1941,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -2027,29 +2021,25 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", - "wasm-bindgen", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -2069,15 +2059,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ "fallible-iterator", - "indexmap 2.9.0", + "indexmap 2.11.4", "stable_deref_trait", ] [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-timers" @@ -2093,16 +2083,16 @@ dependencies = [ [[package]] name = "governor" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" dependencies = [ "cfg-if", "dashmap", "futures-sink", "futures-timer", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.15.5", "nonzero_ext", "parking_lot", "portable-atomic", @@ -2125,9 +2115,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.8" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -2135,7 +2125,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.9.0", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -2160,9 +2150,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", @@ -2176,7 +2166,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.15.5", ] [[package]] @@ -2217,15 +2207,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -2262,13 +2246,13 @@ dependencies = [ [[package]] name = "hostname" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows", + "windows-link 0.1.3", ] [[package]] @@ -2325,13 +2309,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -2339,6 +2324,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2362,9 +2348,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -2386,9 +2372,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2396,7 +2382,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.0", + "windows-core", ] [[package]] @@ -2715,9 +2701,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2747,13 +2733,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.15.5", "serde", + "serde_core", ] [[package]] @@ -2764,9 +2751,9 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inherent" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", @@ -2791,9 +2778,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.1" +version = "1.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" dependencies = [ "console", "once_cell", @@ -2832,15 +2819,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2889,19 +2867,19 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" dependencies = [ "once_cell", "wasm-bindgen", @@ -2979,12 +2957,6 @@ dependencies = [ "spin", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2993,9 +2965,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759bc2b8eabb6a30b235d6f716f7f36479f4b38cbe65b8747aefee51f89e8437" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" dependencies = [ "async-std", "async-trait", @@ -3014,35 +2986,46 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls", + "rustls-platform-verifier", "socket2", "tokio", "tokio-rustls", "tracing", "url", - "webpki-roots", ] [[package]] name = "libc" -version = "0.2.171" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.53.3", ] [[package]] name = "libm" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] [[package]] name = "libsqlite3-sys" @@ -3056,9 +3039,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "listenfd" @@ -3079,15 +3062,15 @@ checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "litrs" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -3095,25 +3078,25 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" dependencies = [ "value-bag", ] [[package]] name = "mach2" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] [[package]] name = "mas-axum-utils" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3138,7 +3121,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", "ulid", @@ -3147,7 +3130,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3188,12 +3171,11 @@ dependencies = [ "opentelemetry-http", "opentelemetry-jaeger-propagator", "opentelemetry-otlp", - "opentelemetry-prometheus", + "opentelemetry-prometheus-text-exporter", "opentelemetry-resource-detectors", "opentelemetry-semantic-conventions", "opentelemetry-stdout", "opentelemetry_sdk", - "prometheus", "rand 0.8.5", "rand_chacha 0.3.1", "reqwest", @@ -3221,7 +3203,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "camino", @@ -3241,7 +3223,7 @@ dependencies = [ "rand_chacha 0.3.1", "rustls-pemfile", "rustls-pki-types", - "schemars", + "schemars 0.9.0", "serde", "serde_json", "serde_with", @@ -3253,7 +3235,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "console", "opentelemetry", @@ -3269,20 +3251,22 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "base64ct", "chrono", "crc", + "lettre", "mas-iana", "mas-jose", "oauth2-types", "rand 0.8.5", + "rand_chacha 0.3.1", "regex", "ruma-common", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", "ulid", "url", "woothee", @@ -3290,18 +3274,18 @@ dependencies = [ [[package]] name = "mas-email" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "async-trait", "lettre", "mas-templates", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", ] [[package]] name = "mas-handlers" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "aide", "anyhow", @@ -3323,13 +3307,14 @@ dependencies = [ "hex", "hyper", "icu_normalizer", - "indexmap 2.9.0", + "indexmap 2.11.4", "insta", "lettre", "mas-axum-utils", "mas-config", "mas-context", "mas-data-model", + "mas-email", "mas-http", "mas-i18n", "mas-iana", @@ -3341,6 +3326,7 @@ dependencies = [ "mas-router", "mas-storage", "mas-storage-pg", + "mas-tasks", "mas-templates", "mime", "minijinja", @@ -3355,7 +3341,7 @@ dependencies = [ "rand_chacha 0.3.1", "reqwest", "rustls", - "schemars", + "schemars 0.9.0", "sentry", "serde", "serde_json", @@ -3363,7 +3349,7 @@ dependencies = [ "serde_with", "sha2", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-util", "tower", @@ -3379,7 +3365,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "futures-util", "headers", @@ -3400,7 +3386,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "camino", "icu_calendar", @@ -3416,13 +3402,13 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", "writeable", ] [[package]] name = "mas-i18n-scan" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "camino", "clap", @@ -3436,15 +3422,15 @@ dependencies = [ [[package]] name = "mas-iana" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ - "schemars", + "schemars 0.9.0", "serde", ] [[package]] name = "mas-iana-codegen" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3452,6 +3438,7 @@ dependencies = [ "convert_case", "csv", "reqwest", + "rustls", "serde", "tokio", "tracing", @@ -3460,7 +3447,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3477,20 +3464,20 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rsa", - "schemars", + "schemars 0.9.0", "sec1", "serde", "serde_json", "serde_with", "sha2", "signature", - "thiserror 2.0.12", + "thiserror 2.0.17", "url", ] [[package]] name = "mas-keystore" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "aead", "base64ct", @@ -3513,12 +3500,12 @@ dependencies = [ "rsa", "sec1", "spki", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] name = "mas-listener" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "bytes", @@ -3530,7 +3517,7 @@ dependencies = [ "pin-project-lite", "rustls-pemfile", "socket2", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-rustls", "tokio-test", @@ -3543,7 +3530,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3553,7 +3540,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3562,7 +3549,7 @@ dependencies = [ "mas-matrix", "reqwest", "serde", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "url", "urlencoding", @@ -3570,7 +3557,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "assert_matches", "async-trait", @@ -3597,7 +3584,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -3606,24 +3593,24 @@ dependencies = [ [[package]] name = "mas-policy" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "arc-swap", "mas-data-model", "oauth2-types", "opa-wasm", - "schemars", + "schemars 0.9.0", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", ] [[package]] name = "mas-router" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "axum", "serde", @@ -3634,16 +3621,16 @@ dependencies = [ [[package]] name = "mas-spa" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "camino", "serde", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] name = "mas-storage" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3656,7 +3643,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "tracing-opentelemetry", "ulid", @@ -3665,7 +3652,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3682,8 +3669,9 @@ dependencies = [ "sea-query", "sea-query-binder", "serde_json", + "sha2", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "ulid", "url", @@ -3692,7 +3680,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3714,7 +3702,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -3724,7 +3712,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3743,7 +3731,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", "ulid", @@ -3754,7 +3742,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "http", "opentelemetry", @@ -3768,11 +3756,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -3793,15 +3781,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memfd" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ "rustix", ] @@ -3830,11 +3818,12 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.10.2" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd72e8b4e42274540edabec853f607c015c73436159b06c39c7af85a20433155" +checksum = "a9f264d75233323f4b7d2f03aefe8a990690cdebfbfe26ea86bcbaec5e9ac990" dependencies = [ "memo-map", + "percent-encoding", "self_cell", "serde", "serde_json", @@ -3843,9 +3832,9 @@ dependencies = [ [[package]] name = "minijinja-contrib" -version = "2.10.2" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457f85f9c4c5b17d11fcf9bbe7c0dbba64843c5ee040005956f1a510b6679fe2" +checksum = "182ba1438db4679ddfa03792c183bdc2b9ce26b58e7d41a749e59b06497cf136" dependencies = [ "minijinja", "serde", @@ -3859,22 +3848,22 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", ] [[package]] @@ -3921,12 +3910,11 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -4005,11 +3993,11 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] @@ -4024,12 +4012,12 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "assert_matches", "base64ct", "chrono", - "indexmap 2.9.0", + "indexmap 2.11.4", "insta", "language-tags", "mas-iana", @@ -4038,7 +4026,7 @@ dependencies = [ "serde_json", "serde_with", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.17", "url", ] @@ -4049,8 +4037,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "crc32fast", - "hashbrown 0.15.2", - "indexmap 2.9.0", + "hashbrown 0.15.5", + "indexmap 2.11.4", "memchr", ] @@ -4061,10 +4049,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "opa-wasm" -version = "0.1.5" +name = "once_cell_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e8c07ec35ceaacb13349669e772705036975bde72b612e72b26a6bd6a71d909" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "opa-wasm" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd2bab45ae1b87f45b4ddea74902158543322dc49bf45d2f714c50bbf8cf44f" dependencies = [ "anyhow", "base64", @@ -4088,7 +4082,7 @@ dependencies = [ "sha1", "sha2", "sprintf", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", "urlencoding", @@ -4110,88 +4104,84 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "opentelemetry" -version = "0.29.1" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" dependencies = [ "futures-core", "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", ] [[package]] name = "opentelemetry-http" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", "http", "opentelemetry", "reqwest", - "tracing", ] [[package]] name = "opentelemetry-jaeger-propagator" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cae2eb16c37705755c3e09332bebdcac9b37ca1539b3ac2d2f43a154401514ae" +checksum = "ba3bbd907f151104a112f749f3b8387ef669b7264e0bb80546ea0700a3b307b7" dependencies = [ "opentelemetry", - "tracing", ] [[package]] name = "opentelemetry-otlp" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656" +checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" dependencies = [ - "futures-core", "http", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", "prost", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] -name = "opentelemetry-prometheus" -version = "0.29.1" +name = "opentelemetry-prometheus-text-exporter" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "098a71a4430bb712be6130ed777335d2e5b19bc8566de5f2edddfce906def6ab" +checksum = "897906366b17a89bec845f6051e0c3474049402a09a0711eea180941293bd013" dependencies = [ - "once_cell", "opentelemetry", "opentelemetry_sdk", - "prometheus", - "tracing", + "smartstring", ] [[package]] name = "opentelemetry-proto" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ "opentelemetry", "opentelemetry_sdk", "prost", "tonic", + "tonic-prost", ] [[package]] name = "opentelemetry-resource-detectors" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c1622068e1c522685d4ec468d60f9d25dc3bc8714e699315dc42488b72e2194" +checksum = "e82845106cf72d47c141cee7f0d95e0650d8f28c6222a1f1ae727a8883899c19" dependencies = [ "opentelemetry", "opentelemetry-semantic-conventions", @@ -4200,59 +4190,50 @@ dependencies = [ [[package]] name = "opentelemetry-semantic-conventions" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b29a9f89f1a954936d5aa92f19b2feec3c8f3971d3e96206640db7f9706ae3" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" [[package]] name = "opentelemetry-stdout" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e27d446dabd68610ef0b77d07b102ecde827a4596ea9c01a4d3811e945b286" +checksum = "bc8887887e169414f637b18751487cce4e095be787d23fad13c454e2fb1b3811" dependencies = [ "chrono", - "futures-util", "opentelemetry", "opentelemetry_sdk", ] [[package]] name = "opentelemetry_sdk" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" dependencies = [ "futures-channel", "futures-executor", "futures-util", - "glob", "opentelemetry", "percent-encoding", - "rand 0.9.0", - "serde_json", - "thiserror 2.0.12", + "rand 0.9.2", + "thiserror 2.0.17", "tokio", "tokio-stream", - "tracing", ] [[package]] name = "os_info" -version = "3.10.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" dependencies = [ "log", + "plist", "serde", "windows-sys 0.52.0", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "p256" version = "0.13.2" @@ -4294,9 +4275,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -4304,9 +4285,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", @@ -4321,15 +4302,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" -[[package]] -name = "parse-zoneinfo" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" -dependencies = [ - "regex", -] - [[package]] name = "password-hash" version = "0.5.0" @@ -4341,12 +4313,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -4394,26 +4360,25 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" dependencies = [ "memchr", - "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" dependencies = [ "pest", "pest_generator", @@ -4421,9 +4386,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" dependencies = [ "pest", "pest_meta", @@ -4434,49 +4399,28 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" dependencies = [ - "once_cell", "pest", "sha2", ] [[package]] name = "phf" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ "phf_shared", ] -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - [[package]] name = "phf_shared" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ "siphasher", ] @@ -4569,18 +4513,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] -name = "polling" -version = "3.7.4" +name = "plist" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap 2.11.4", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi 0.4.0", + "hermit-abi", "pin-project-lite", "rustix", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -4608,15 +4564,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "postcard" -version = "1.1.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ "cobs", "embedded-io 0.4.0", @@ -4636,14 +4592,14 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] name = "prettyplease" -version = "0.2.32" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -4660,18 +4616,18 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -4689,26 +4645,11 @@ dependencies = [ "yansi", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "protobuf", - "thiserror 2.0.12", -] - [[package]] name = "prost" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", "prost-derive", @@ -4716,9 +4657,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", "itertools 0.14.0", @@ -4727,31 +4668,11 @@ dependencies = [ "syn", ] -[[package]] -name = "protobuf" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" -dependencies = [ - "once_cell", - "protobuf-support", - "thiserror 1.0.69", -] - -[[package]] -name = "protobuf-support" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" -dependencies = [ - "thiserror 1.0.69", -] - [[package]] name = "psl" -version = "2.1.120" +version = "2.1.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc02eae5cf5475af3fde3792e68ff74eb8625638fcaa8f1ffb91b7b98bfae4a5" +checksum = "53297a72c400b31c5facd8e50894d08d20b74ee74925b28a20d51fe48c863583" dependencies = [ "psl-types", ] @@ -4764,22 +4685,34 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] name = "psm" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" dependencies = [ "cc", ] [[package]] name = "pulley-interpreter" -version = "31.0.0" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3325791708ad50580aeacfcce06cb5e462c9ba7a2368e109cb2012b944b70e" +checksum = "b89c4319786b16c1a6a38ee04788d32c669b61ba4b69da2162c868c18be99c1b" dependencies = [ "cranelift-bitset", "log", - "wasmtime-math", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938543690519c20c3a480d20a8efcc8e69abeb44093ab1df4e7c1f81f26c677a" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -4792,63 +4725,18 @@ dependencies = [ "libc", "once_cell", "raw-cpuid", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "web-sys", "winapi", ] [[package]] -name = "quinn" -version = "0.11.7" +name = "quick-xml" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.1", - "rustls", - "socket2", - "thiserror 2.0.12", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" -dependencies = [ - "bytes", - "getrandom 0.3.2", - "rand 0.9.0", - "ring", - "rustc-hash 2.1.1", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.12", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.59.0", + "memchr", ] [[package]] @@ -4868,9 +4756,9 @@ checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" @@ -4885,13 +4773,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy 0.8.24", ] [[package]] @@ -4920,7 +4807,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -4929,23 +4816,23 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", ] [[package]] name = "raw-cpuid" -version = "11.5.0" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df7ab838ed27997ba19a4664507e6f82b41fe6e20be42929332156e5e85146" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ "bitflags", ] [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -4953,9 +4840,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -4963,9 +4850,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] @@ -4992,67 +4879,52 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.11.2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.2", + "hashbrown 0.15.5", "log", - "rustc-hash 2.1.1", + "rustc-hash", "smallvec", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64", "bytes", @@ -5072,7 +4944,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "serde", @@ -5108,7 +4979,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", @@ -5136,15 +5007,15 @@ dependencies = [ [[package]] name = "ruma-common" -version = "0.15.2" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b75da013b362664c3e161662902e5da3f77e990525681b59c6035bac27e87b4" +checksum = "ac7f59b9f7639667d0d6ae3ae242c8912e9ed061cea1fbaf72710a402e83b53e" dependencies = [ "as_variant", "base64", "bytes", "form_urlencoded", - "indexmap 2.9.0", + "indexmap 2.11.4", "js_int", "percent-encoding", "regex", @@ -5153,29 +5024,30 @@ dependencies = [ "serde", "serde_html_form", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "url", "web-time", "wildmatch", + "zeroize", ] [[package]] name = "ruma-identifiers-validation" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ad674b5e5368c53a2c90fde7dac7e30747004aaf7b1827b72874a25fc06d4d8" +checksum = "14a7b93ac1e571c585f8fa5cef09c07bb8a15529775fd56b9a3eac4f9233dff2" dependencies = [ "js_int", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] name = "ruma-macros" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1182e83ee5cd10121974f163337b16af68a93eedfc7cdbdbd52307ac7e1d743" +checksum = "0c9911c7188517f28505d2d513339511d00e0f50cec5c2dde820cd0ec7e6a833" dependencies = [ "cfg-if", "proc-macro-crate", @@ -5189,9 +5061,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.37.1" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" +checksum = "c8975fc98059f365204d635119cf9c5a60ae67b841ed49b5422a9a7e56cdfac0" dependencies = [ "arrayvec", "num-traits", @@ -5199,15 +5071,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -5226,27 +5092,26 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5280,15 +5145,14 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ - "web-time", "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +checksum = "be59af91596cac372a6942530653ad0c3a246cdd491aaa9dcaee47f88d67d5a0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -5313,9 +5177,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.2" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "aws-lc-rs", "ring", @@ -5325,9 +5189,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -5355,11 +5219,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -5370,7 +5234,7 @@ checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ "chrono", "dyn-clone", - "indexmap 2.9.0", + "indexmap 2.11.4", "ref-cast", "schemars_derive", "serde", @@ -5378,6 +5242,18 @@ dependencies = [ "url", ] +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.9.0" @@ -5418,9 +5294,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "0.32.6" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" dependencies = [ "chrono", "inherent", @@ -5451,7 +5327,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -5470,9 +5346,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" dependencies = [ "bitflags", "core-foundation", @@ -5483,9 +5359,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -5493,21 +5369,21 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" +checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "sentry" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "255914a8e53822abd946e2ce8baa41d4cded6b8e938913b7f7b9da5b7ab44335" +checksum = "48b85e25e8a1fc13928885e8bf13abe8a09e15c46993aed05d6405f7755d6e20" dependencies = [ "httpdate", "reqwest", @@ -5522,21 +5398,20 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00293cd332a859961f24fd69258f7e92af736feaeb91020cff84dac4188a4302" +checksum = "f3253a495ab536f6de1746a58d5d7824b77d75e08e1a4b8ca6fb356839077ae0" dependencies = [ "backtrace", - "once_cell", "regex", "sentry-core", ] [[package]] name = "sentry-contexts" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961990f9caa76476c481de130ada05614cd7f5aa70fb57c2142f0e09ad3fb2aa" +checksum = "027f81a728836e66b88c07666a10f5ed5a35e2695b04eb7aa0fcbed93f814900" dependencies = [ "hostname", "libc", @@ -5548,22 +5423,22 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a6409d845707d82415c800290a5d63be5e3df3c2e417b0997c60531dfbd35ef" +checksum = "d3b6729c8e71ac968edbe9bf2dd4109c162e552b52bacd2b07e24ede1aba84a5" dependencies = [ - "once_cell", - "rand 0.8.5", + "rand 0.9.2", "sentry-types", "serde", "serde_json", + "url", ] [[package]] name = "sentry-panic" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "609b1a12340495ce17baeec9e08ff8ed423c337c1a84dffae36a178c783623f3" +checksum = "1ac0471f04f8f97af0c17eeca2c516e23faa1c0271a55bc64371d9ce488c2d40" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5571,9 +5446,9 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b98005537e38ee3bc10e7d36e7febe9b8e573d03f2ddd85fcdf05d21f9abd6d" +checksum = "417bd48071863a65ca5f33d15af9aabd49a5cee7f97415d3f08ce8c90ed2c531" dependencies = [ "axum", "http", @@ -5586,10 +5461,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f4e86402d5c50239dc7d8fd3f6d5e048221d5fcb4e026d8d50ab57fe4644cb" +checksum = "428f780866a613142dcc81b7f8551ae4d1c056f4df22b6d7ddd9154a9974eb03" dependencies = [ + "bitflags", "sentry-backtrace", "sentry-core", "tracing-core", @@ -5598,16 +5474,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3f117b8755dbede8260952de2aeb029e20f432e72634e8969af34324591631" +checksum = "2c19d1d1967b55659c358886d0f1aa3076488d445f84c7d727d384c675adaec1" dependencies = [ "debugid", "hex", - "rand 0.8.5", + "rand 0.9.2", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", "time", "url", "uuid", @@ -5615,18 +5491,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -5646,38 +5532,40 @@ dependencies = [ [[package]] name = "serde_html_form" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", - "indexmap 2.9.0", + "indexmap 2.11.4", "itoa", "ryu", - "serde", + "serde_core", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.11.4", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -5690,14 +5578,14 @@ dependencies = [ "futures", "percent-encoding", "serde", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] name = "serde_spanned" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -5716,15 +5604,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.9.0", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", "serde", "serde_derive", "serde_json", @@ -5734,9 +5624,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ "darling", "proc-macro2", @@ -5750,7 +5640,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.11.4", "itoa", "ryu", "serde", @@ -5802,9 +5692,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -5833,30 +5723,38 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] -name = "socket2" -version = "0.5.10" +name = "smartstring" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5889,19 +5787,13 @@ dependencies = [ [[package]] name = "sprintf" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46781e6f401f1557f5b4560284baf7268bd9ca531e9e387120a8695fe5bc1fb1" +checksum = "78222247fc55e10208ed1ba60f8296390bc67a489bc27a36231765d8d6f60ec5" dependencies = [ - "thiserror 1.0.69", + "thiserror 2.0.17", ] -[[package]] -name = "sptr" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" - [[package]] name = "sqlx" version = "0.8.6" @@ -5927,14 +5819,14 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.15.5", "hashlink", - "indexmap 2.9.0", + "indexmap 2.11.4", "ipnetwork", "log", "memchr", @@ -5945,13 +5837,13 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", "url", "uuid", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -6030,7 +5922,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -6070,7 +5962,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -6096,7 +5988,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "url", "uuid", @@ -6110,9 +6002,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stacker" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601f9201feb9b09c00266478bf459952b9ef9a6b94edb2f21eba14ab681a60a9" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" dependencies = [ "cc", "cfg-if", @@ -6180,9 +6072,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.100" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -6191,7 +6083,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "0.17.0-rc.0" +version = "1.6.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -6203,6 +6095,7 @@ dependencies = [ "futures-util", "insta", "mas-config", + "mas-data-model", "mas-iana", "mas-storage", "mas-storage-pg", @@ -6211,11 +6104,11 @@ dependencies = [ "opentelemetry-semantic-conventions", "rand 0.8.5", "rand_chacha 0.3.1", - "rustc-hash 2.1.1", + "rustc-hash", "serde", "serde_json", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.17", "thiserror-ext", "tokio", "tokio-util", @@ -6236,9 +6129,9 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -6247,22 +6140,21 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" -version = "3.15.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ - "cfg-if", "fastrand", - "getrandom 0.2.15", + "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -6285,28 +6177,28 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] name = "thiserror-ext" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4323942237f7cc071061f2c5f0db919e6053c2cdf58c6bc974883073429737" +checksum = "5fb7e61141f4141832ca9aad63c3c90023843f944a1975460abdacc64d03f534" dependencies = [ - "thiserror 1.0.69", + "thiserror 2.0.17", "thiserror-ext-derive", ] [[package]] name = "thiserror-ext-derive" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96541747c50e6c73e094737938f4f5dfaf50c48a31adff4197a3e2a481371674" +checksum = "2b5042dd3b562d1d57711be902006a0003fa2781b81d5b2bec07416be31586ff" dependencies = [ "either", "proc-macro2", @@ -6327,9 +6219,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -6338,22 +6230,20 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -6365,15 +6255,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -6391,9 +6281,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -6406,11 +6296,10 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", "libc", "mio", @@ -6419,14 +6308,14 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -6435,9 +6324,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -6469,24 +6358,23 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", - "hashbrown 0.15.2", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.8.19" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", @@ -6496,31 +6384,31 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.11.4", "serde", "serde_spanned", "toml_datetime", - "winnow", + "winnow 0.7.13", ] [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "base64", @@ -6530,13 +6418,24 @@ dependencies = [ "http-body-util", "percent-encoding", "pin-project", - "prost", + "sync_wrapper", "tokio-stream", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.2" @@ -6619,9 +6518,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", @@ -6630,9 +6529,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -6663,14 +6562,15 @@ dependencies = [ [[package]] name = "tracing-opentelemetry" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd8e764bd6f5813fd8bebc3117875190c5b0415be8f7f8059bffb6ecd979c444" +checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e" dependencies = [ "js-sys", - "once_cell", "opentelemetry", "opentelemetry_sdk", + "rustversion", + "thiserror 2.0.17", "tracing", "tracing-core", "tracing-subscriber", @@ -6679,14 +6579,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -6768,9 +6668,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-normalization" @@ -6801,9 +6701,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "unicode-xid" @@ -6835,9 +6735,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -6871,9 +6771,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "js-sys", "serde", @@ -6969,17 +6869,26 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", ] [[package]] @@ -6990,21 +6899,22 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" dependencies = [ "bumpalo", "log", @@ -7016,9 +6926,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" dependencies = [ "cfg-if", "js-sys", @@ -7029,9 +6939,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7039,9 +6949,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" dependencies = [ "proc-macro2", "quote", @@ -7052,18 +6962,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-encoder" -version = "0.226.0" +version = "0.235.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d81b727619aec227dce83e7f7420d4e56c79acd044642a356ea045b98d4e13" +checksum = "b3bc393c395cb621367ff02d854179882b9a351b4e0c93d1397e6090b53a5c2a" dependencies = [ "leb128fmt", "wasmparser", @@ -7071,22 +6981,22 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.226.0" +version = "0.235.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc28600dcb2ba68d7e5f1c3ba4195c2bddc918c0243fd702d0b6dbd05689b681" +checksum = "161296c618fa2d63f6ed5fffd1112937e803cb9ec71b32b01a76321555660917" dependencies = [ "bitflags", - "hashbrown 0.15.2", - "indexmap 2.9.0", + "hashbrown 0.15.5", + "indexmap 2.11.4", "semver", "serde", ] [[package]] name = "wasmprinter" -version = "0.226.0" +version = "0.235.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "753a0516fa6c01756ee861f36878dfd9875f273aea9409d9ea390a333c5bcdc2" +checksum = "75aa8e9076de6b9544e6dab4badada518cca0bf4966d35b131bbd057aed8fa0a" dependencies = [ "anyhow", "termcolor", @@ -7095,9 +7005,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "31.0.0" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9fe78033c72da8741e724d763daf1375c93a38bfcea99c873ee4415f6098c3f" +checksum = "b6fe976922a16af3b0d67172c473d1fd4f1aa5d0af9c8ba6538c741f3af686f4" dependencies = [ "addr2line", "anyhow", @@ -7106,106 +7016,48 @@ dependencies = [ "bumpalo", "cc", "cfg-if", - "hashbrown 0.15.2", - "indexmap 2.9.0", + "hashbrown 0.15.5", + "indexmap 2.11.4", "libc", "log", "mach2", "memfd", "object", "once_cell", - "paste", "postcard", - "psm", "pulley-interpreter", "rayon", "rustix", "serde", "serde_derive", "smallvec", - "sptr", "target-lexicon", "trait-variant", "wasmparser", - "wasmtime-asm-macros", - "wasmtime-component-macro", - "wasmtime-cranelift", "wasmtime-environ", - "wasmtime-fiber", - "wasmtime-jit-icache-coherence", - "wasmtime-math", - "wasmtime-slab", - "wasmtime-versioned-export-macros", + "wasmtime-internal-asm-macros", + "wasmtime-internal-component-macro", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", "windows-sys 0.59.0", ] -[[package]] -name = "wasmtime-asm-macros" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47f3d44ae977d70ccf80938b371d5ec60b6adedf60800b9e8dd1223bb69f4cbc" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "wasmtime-component-macro" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397e68ee29eb072d8d8741c9d2c971a284cd1bc960ebf2c1f6a33ea6ba16d6e1" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn", - "wasmtime-component-util", - "wasmtime-wit-bindgen", - "wit-parser", -] - -[[package]] -name = "wasmtime-component-util" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f292ef5eb2cf3d414c2bde59c7fa0feeba799c8db9a8c5a656ad1d1a1d05e10b" - -[[package]] -name = "wasmtime-cranelift" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52fc12eb8ea695a30007a4849a5fd56209dd86a15579e92e0c27c27122818505" -dependencies = [ - "anyhow", - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools 0.12.1", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon", - "thiserror 1.0.69", - "wasmparser", - "wasmtime-environ", - "wasmtime-versioned-export-macros", -] - [[package]] name = "wasmtime-environ" -version = "31.0.0" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6b4bf08e371edf262cccb62de10e214bd4aaafaa069f1cd49c9c1c3a5ae8e4" +checksum = "44b6264a78d806924abbc76bbc75eac24976bc83bdfb938e5074ae551242436f" dependencies = [ "anyhow", "cranelift-bitset", "cranelift-entity", "gimli", - "indexmap 2.9.0", + "indexmap 2.11.4", "log", "object", "postcard", @@ -7219,25 +7071,83 @@ dependencies = [ ] [[package]] -name = "wasmtime-fiber" -version = "31.0.0" +name = "wasmtime-internal-asm-macros" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8828d7d8fbe90d087a9edea9223315caf7eb434848896667e5d27889f1173" +checksum = "6775a9b516559716e5710e95a8014ca0adcc81e5bf4d3ad7899d89ae40094d1a" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3d098205e405e6b5ced06c1815621b823464b6ea289eaafe494139b0aee287" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219252067216242ed2b32665611b0ee356d6e92cbb897ecb9a10cae0b97bdeca" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec9ad7565e6a8de7cb95484e230ff689db74a4a085219e0da0cbd637a29c01c" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.17", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b636ff8b220ebaf29dfe3b23770e4b2bad317b9683e3bf7345e162387385b39" dependencies = [ "anyhow", "cc", "cfg-if", + "libc", "rustix", - "wasmtime-asm-macros", - "wasmtime-versioned-export-macros", + "wasmtime-internal-asm-macros", + "wasmtime-internal-versioned-export-macros", "windows-sys 0.59.0", ] [[package]] -name = "wasmtime-jit-icache-coherence" -version = "31.0.0" +name = "wasmtime-internal-jit-icache-coherence" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a54f6c6c7e9d7eeee32dfcc10db7f29d505ee7dd28d00593ea241d5f70698e64" +checksum = "4417e06b7f80baff87d9770852c757a39b8d7f11d78b2620ca992b8725f16f50" dependencies = [ "anyhow", "cfg-if", @@ -7246,25 +7156,38 @@ dependencies = [ ] [[package]] -name = "wasmtime-math" -version = "31.0.0" +name = "wasmtime-internal-math" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1108aad2e6965698f9207ea79b80eda2b3dcc57dcb69f4258296d4664ae32cd" +checksum = "7710d5c4ecdaa772927fd11e5dc30a9a62d1fc8fe933e11ad5576ad596ab6612" dependencies = [ "libm", ] [[package]] -name = "wasmtime-slab" -version = "31.0.0" +name = "wasmtime-internal-slab" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d6a321317281b721c5530ef733e8596ecc6065035f286ccd155b3fa8e0ab2f" +checksum = "e6ab22fabe1eed27ab01fd47cd89deacf43ad222ed7fd169ba6f4dd1fbddc53b" [[package]] -name = "wasmtime-versioned-export-macros" -version = "31.0.0" +name = "wasmtime-internal-unwinder" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5732a5c86efce7bca121a61d8c07875f6b85c1607aa86753b40f7f8bd9d3a780" +checksum = "307708f302f5dcf19c1bbbfb3d9f2cbc837dd18088a7988747b043a46ba38ecc" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "342b0466f92b7217a4de9e114175fedee1907028567d2548bcd42f71a8b5b016" dependencies = [ "proc-macro2", "quote", @@ -7272,22 +7195,22 @@ dependencies = [ ] [[package]] -name = "wasmtime-wit-bindgen" -version = "31.0.0" +name = "wasmtime-internal-wit-bindgen" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505c13fa0cac6c43e805347acf1e916c8de54e3790f2c22873c5692964b09b62" +checksum = "1ae057d44a5b60e6ec529b0c21809a9d1fc92e91ef6e0f6771ed11dd02a94a08" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.9.0", + "indexmap 2.11.4", "wit-parser", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" dependencies = [ "js-sys", "wasm-bindgen", @@ -7305,49 +7228,46 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "0.26.8" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "0.26.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix", -] - [[package]] name = "whoami" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall", + "libredox", "wasite", ] [[package]] name = "wildmatch" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" +checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" [[package]] name = "winapi" @@ -7367,11 +7287,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -7380,34 +7300,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.0", "windows-result", "windows-strings", ] @@ -7436,26 +7337,32 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-result" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -7494,6 +7401,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -7533,13 +7458,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7558,6 +7500,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -7576,6 +7524,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -7594,12 +7548,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -7618,6 +7584,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -7636,6 +7608,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -7654,6 +7632,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -7672,6 +7656,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.6.26" @@ -7682,13 +7672,21 @@ dependencies = [ ] [[package]] -name = "wiremock" -version = "0.6.3" +name = "winnow" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "101681b74cd87b5899e87bcf5a64e83334dd313fcd3053ea72e6dba18928e301" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "async-trait", "base64", "deadpool", "futures", @@ -7706,23 +7704,20 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wit-parser" -version = "0.226.0" +version = "0.235.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f007722bfd43a2978c5b8b90f02c927dddf0f11c5f5b50929816b3358718cd" +checksum = "0a1f95a87d03a33e259af286b857a95911eb46236a0f726cbaec1227b3dfc67a" dependencies = [ "anyhow", "id-arena", - "indexmap 2.9.0", + "indexmap 2.11.4", "log", "semver", "serde", @@ -7789,38 +7784,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", @@ -7850,9 +7825,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 958ba8e3b..76a60ee5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,16 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [workspace] default-members = ["crates/cli"] members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "0.17.0-rc.0" -package.license = "AGPL-3.0-only" +package.version = "1.6.0-rc.0" +package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" package.homepage = "https://element-hq.github.io/matrix-authentication-service/" @@ -21,6 +26,7 @@ all = { level = "deny", priority = -1 } pedantic = { level = "warn", priority = -1 } str_to_string = "deny" +too_many_lines = "allow" [workspace.lints.rustdoc] broken_intra_doc_links = "deny" @@ -28,40 +34,40 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.17.0-rc.0" } -mas-cli = { path = "./crates/cli/", version = "=0.17.0-rc.0" } -mas-config = { path = "./crates/config/", version = "=0.17.0-rc.0" } -mas-context = { path = "./crates/context/", version = "=0.17.0-rc.0" } -mas-data-model = { path = "./crates/data-model/", version = "=0.17.0-rc.0" } -mas-email = { path = "./crates/email/", version = "=0.17.0-rc.0" } -mas-graphql = { path = "./crates/graphql/", version = "=0.17.0-rc.0" } -mas-handlers = { path = "./crates/handlers/", version = "=0.17.0-rc.0" } -mas-http = { path = "./crates/http/", version = "=0.17.0-rc.0" } -mas-i18n = { path = "./crates/i18n/", version = "=0.17.0-rc.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.17.0-rc.0" } -mas-iana = { path = "./crates/iana/", version = "=0.17.0-rc.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.17.0-rc.0" } -mas-jose = { path = "./crates/jose/", version = "=0.17.0-rc.0" } -mas-keystore = { path = "./crates/keystore/", version = "=0.17.0-rc.0" } -mas-listener = { path = "./crates/listener/", version = "=0.17.0-rc.0" } -mas-matrix = { path = "./crates/matrix/", version = "=0.17.0-rc.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.17.0-rc.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.17.0-rc.0" } -mas-policy = { path = "./crates/policy/", version = "=0.17.0-rc.0" } -mas-router = { path = "./crates/router/", version = "=0.17.0-rc.0" } -mas-spa = { path = "./crates/spa/", version = "=0.17.0-rc.0" } -mas-storage = { path = "./crates/storage/", version = "=0.17.0-rc.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.17.0-rc.0" } -mas-tasks = { path = "./crates/tasks/", version = "=0.17.0-rc.0" } -mas-templates = { path = "./crates/templates/", version = "=0.17.0-rc.0" } -mas-tower = { path = "./crates/tower/", version = "=0.17.0-rc.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=0.17.0-rc.0" } -syn2mas = { path = "./crates/syn2mas", version = "=0.17.0-rc.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.6.0-rc.0" } +mas-cli = { path = "./crates/cli/", version = "=1.6.0-rc.0" } +mas-config = { path = "./crates/config/", version = "=1.6.0-rc.0" } +mas-context = { path = "./crates/context/", version = "=1.6.0-rc.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.6.0-rc.0" } +mas-email = { path = "./crates/email/", version = "=1.6.0-rc.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.6.0-rc.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.6.0-rc.0" } +mas-http = { path = "./crates/http/", version = "=1.6.0-rc.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.6.0-rc.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.6.0-rc.0" } +mas-iana = { path = "./crates/iana/", version = "=1.6.0-rc.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.6.0-rc.0" } +mas-jose = { path = "./crates/jose/", version = "=1.6.0-rc.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.6.0-rc.0" } +mas-listener = { path = "./crates/listener/", version = "=1.6.0-rc.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.6.0-rc.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.6.0-rc.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.6.0-rc.0" } +mas-policy = { path = "./crates/policy/", version = "=1.6.0-rc.0" } +mas-router = { path = "./crates/router/", version = "=1.6.0-rc.0" } +mas-spa = { path = "./crates/spa/", version = "=1.6.0-rc.0" } +mas-storage = { path = "./crates/storage/", version = "=1.6.0-rc.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.6.0-rc.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.6.0-rc.0" } +mas-templates = { path = "./crates/templates/", version = "=1.6.0-rc.0" } +mas-tower = { path = "./crates/tower/", version = "=1.6.0-rc.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.6.0-rc.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.6.0-rc.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] version = "0.15.0" -features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"] +features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"] # An `Arc` that can be atomically updated [workspace.dependencies.arc-swap] @@ -78,11 +84,11 @@ version = "0.3.6" # Utility to write and implement async traits [workspace.dependencies.async-trait] -version = "0.1.88" +version = "0.1.89" # High-level error handling [workspace.dependencies.anyhow] -version = "1.0.98" +version = "1.0.100" # Assert that a value matches a pattern [workspace.dependencies.assert_matches] @@ -90,12 +96,12 @@ version = "1.5.0" # HTTP router [workspace.dependencies.axum] -version = "0.8.4" +version = "0.8.6" # Extra utilities for Axum [workspace.dependencies.axum-extra] -version = "0.10.1" -features = ["cookie-private", "cookie-key-expansion", "typed-header"] +version = "0.10.3" +features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"] # Axum macros [workspace.dependencies.axum-macros] @@ -118,12 +124,12 @@ features = ["std"] # Bcrypt password hashing [workspace.dependencies.bcrypt] -version = "0.17.0" +version = "0.17.1" default-features = true # Packed bitfields [workspace.dependencies.bitflags] -version = "2.9.1" +version = "2.9.4" # Bytes [workspace.dependencies.bytes] @@ -131,7 +137,7 @@ version = "1.10.1" # UTF-8 paths [workspace.dependencies.camino] -version = "1.1.10" +version = "1.2.1" features = ["serde1"] # ChaCha20Poly1305 AEAD @@ -149,19 +155,19 @@ version = "0.15.11" # Cookie store [workspace.dependencies.cookie_store] -version = "0.21.1" +version = "0.22.0" default-features = false features = ["serde_json"] # Time utilities [workspace.dependencies.chrono] -version = "0.4.41" +version = "0.4.42" default-features = false features = ["serde", "clock"] # CLI argument parsing [workspace.dependencies.clap] -version = "4.5.40" +version = "4.5.50" features = ["derive"] # Object Identifiers (OIDs) as constants @@ -221,7 +227,7 @@ features = ["env", "yaml", "test"] # URL form encoding [workspace.dependencies.form_urlencoded] -version = "1.2.1" +version = "1.2.2" # Utilities for dealing with futures [workspace.dependencies.futures-util] @@ -233,7 +239,7 @@ version = "0.14.7" # Rate-limiting [workspace.dependencies.governor] -version = "0.10.0" +version = "0.10.1" default-features = false features = ["std", "dashmap", "quanta"] @@ -263,12 +269,12 @@ version = "0.1.3" # HTTP client and server [workspace.dependencies.hyper] -version = "1.6.0" +version = "1.7.0" features = ["client", "server", "http1", "http2"] # Additional Hyper utilties [workspace.dependencies.hyper-util] -version = "0.1.14" +version = "0.1.17" features = [ "client", "server", @@ -315,7 +321,7 @@ features = ["std"] # HashMap which preserves insertion order [workspace.dependencies.indexmap] -version = "2.9.0" +version = "2.11.4" features = ["serde"] # Indented string literals @@ -324,7 +330,7 @@ version = "2.0.6" # Snapshot testing [workspace.dependencies.insta] -version = "1.43.1" +version = "1.43.2" features = ["yaml", "json"] # IP network address types @@ -348,10 +354,12 @@ features = ["serde"] # Email sending [workspace.dependencies.lettre] -version = "0.11.15" +version = "0.11.19" default-features = false features = [ - "tokio1-rustls-tls", + "tokio1-rustls", + "rustls-platform-verifier", + "aws-lc-rs", "hostname", "builder", "tracing", @@ -370,12 +378,12 @@ version = "0.3.17" # Templates [workspace.dependencies.minijinja] -version = "2.10.2" -features = ["loader", "json", "speedups", "unstable_machinery"] +version = "2.12.0" +features = ["urlencode", "loader", "json", "speedups", "unstable_machinery"] # Additional filters for minijinja [workspace.dependencies.minijinja-contrib] -version = "2.10.2" +version = "2.12.0" features = ["pycompat"] # Utilities to deal with non-zero values @@ -384,40 +392,40 @@ version = "0.3.0" # Open Policy Agent support through WASM [workspace.dependencies.opa-wasm] -version = "0.1.5" +version = "0.1.7" # OpenTelemetry [workspace.dependencies.opentelemetry] -version = "0.29.1" +version = "0.31.0" features = ["trace", "metrics"] [workspace.dependencies.opentelemetry-http] -version = "0.29.0" +version = "0.31.0" features = ["reqwest"] [workspace.dependencies.opentelemetry-jaeger-propagator] -version = "0.29.0" +version = "0.31.0" [workspace.dependencies.opentelemetry-otlp] -version = "0.29.0" +version = "0.31.0" default-features = false features = ["trace", "metrics", "http-proto"] -[workspace.dependencies.opentelemetry-prometheus] -version = "0.29.1" +[workspace.dependencies.opentelemetry-prometheus-text-exporter] +version = "0.2.1" [workspace.dependencies.opentelemetry-resource-detectors] -version = "0.8.0" +version = "0.10.0" [workspace.dependencies.opentelemetry-semantic-conventions] -version = "0.29.0" +version = "0.31.0" features = ["semconv_experimental"] [workspace.dependencies.opentelemetry-stdout] -version = "0.29.0" +version = "0.31.0" features = ["trace", "metrics"] [workspace.dependencies.opentelemetry_sdk] -version = "0.29.0" +version = "0.31.0" features = [ "experimental_trace_batch_span_processor_with_async_runtime", "experimental_metrics_periodicreader_with_async_runtime", "rt-tokio", ] [workspace.dependencies.tracing-opentelemetry] -version = "0.30.0" +version = "0.32.0" default-features = false # P256 elliptic curve @@ -446,11 +454,11 @@ features = ["std"] # Parser generator [workspace.dependencies.pest] -version = "2.8.0" +version = "2.8.3" # Pest derive macros [workspace.dependencies.pest_derive] -version = "2.8.0" +version = "2.8.3" # Pin projection [workspace.dependencies.pin-project-lite] @@ -468,11 +476,7 @@ features = ["std", "pkcs5", "encryption"] # Public Suffix List [workspace.dependencies.psl] -version = "2.1.120" - -# Prometheus metrics -[workspace.dependencies.prometheus] -version = "0.14.0" +version = "2.1.148" # High-precision clock [workspace.dependencies.quanta] @@ -488,13 +492,19 @@ version = "0.6.4" # Regular expressions [workspace.dependencies.regex] -version = "1.11.1" +version = "1.12.2" # High-level HTTP client [workspace.dependencies.reqwest] -version = "0.12.20" +version = "0.12.24" default-features = false -features = ["http2", "rustls-tls-manual-roots", "charset", "json", "socks"] +features = [ + "http2", + "rustls-tls-manual-roots-no-provider", + "charset", + "json", + "socks", +] # RSA cryptography [workspace.dependencies.rsa] @@ -507,11 +517,11 @@ version = "2.1.1" # Matrix-related types [workspace.dependencies.ruma-common] -version = "0.15.2" +version = "0.16.0" # TLS stack [workspace.dependencies.rustls] -version = "0.23.27" +version = "0.23.34" # PEM parsing for rustls [workspace.dependencies.rustls-pemfile] @@ -523,7 +533,7 @@ version = "1.12.0" # Use platform-specific verifier for TLS [workspace.dependencies.rustls-platform-verifier] -version = "0.5.3" +version = "0.6.1" # systemd service status notification [workspace.dependencies.sd-notify] @@ -541,7 +551,7 @@ features = ["std"] # Query builder [workspace.dependencies.sea-query] -version = "0.32.6" +version = "0.32.7" features = ["derive", "attr", "with-uuid", "with-chrono", "postgres-array"] # Query builder @@ -557,27 +567,27 @@ features = [ # Sentry error tracking [workspace.dependencies.sentry] -version = "0.37.0" +version = "0.45.0" default-features = false features = ["backtrace", "contexts", "panic", "tower", "reqwest"] # Sentry tower layer [workspace.dependencies.sentry-tower] -version = "0.37.0" +version = "0.45.0" features = ["http", "axum-matched-path"] # Sentry tracing integration [workspace.dependencies.sentry-tracing] -version = "0.37.0" +version = "0.45.0" # Serialization and deserialization [workspace.dependencies.serde] -version = "1.0.219" +version = "1.0.228" features = ["derive"] # Most of the time, if we need serde, we need derive # JSON serialization and deserialization [workspace.dependencies.serde_json] -version = "1.0.140" +version = "1.0.145" features = ["preserve_order"] # URL encoded form serialization @@ -586,7 +596,7 @@ version = "0.7.1" # Custom serialization helpers [workspace.dependencies.serde_with] -version = "3.12.0" +version = "3.14.0" features = ["hex", "chrono"] # YAML serialization @@ -604,7 +614,7 @@ version = "2.2.0" # Low-level socket manipulation [workspace.dependencies.socket2] -version = "0.5.10" +version = "0.6.1" # Subject Public Key Info [workspace.dependencies.spki] @@ -627,14 +637,14 @@ features = [ # Custom error types [workspace.dependencies.thiserror] -version = "2.0.12" +version = "2.0.17" [workspace.dependencies.thiserror-ext] -version = "0.2.1" +version = "0.3.0" # Async runtime [workspace.dependencies.tokio] -version = "1.45.1" +version = "1.48.0" features = ["full"] [workspace.dependencies.tokio-stream] @@ -642,7 +652,7 @@ version = "0.1.17" # Tokio rustls integration [workspace.dependencies.tokio-rustls] -version = "0.26.2" +version = "0.26.4" # Tokio test utilities [workspace.dependencies.tokio-test] @@ -650,7 +660,7 @@ version = "0.4.4" # Useful async utilities [workspace.dependencies.tokio-util] -version = "0.7.15" +version = "0.7.16" features = ["rt"] # Tower services @@ -675,14 +685,14 @@ features = ["cors", "fs", "add-extension", "set-header"] [workspace.dependencies.tracing] version = "0.1.41" [workspace.dependencies.tracing-subscriber] -version = "0.3.19" +version = "0.3.20" features = ["env-filter"] [workspace.dependencies.tracing-appender] version = "0.2.3" # URL manipulation [workspace.dependencies.url] -version = "2.5.4" +version = "2.5.7" features = ["serde"] # URL encoding @@ -696,7 +706,7 @@ features = ["serde", "uuid"] # UUID support [workspace.dependencies.uuid] -version = "1.17.0" +version = "1.18.1" # HTML escaping [workspace.dependencies.v_htmlescape] @@ -713,7 +723,7 @@ version = "2.5.0" # HTTP mock server [workspace.dependencies.wiremock] -version = "0.6.3" +version = "0.6.5" # User-agent parser [workspace.dependencies.woothee] @@ -725,7 +735,7 @@ version = "0.5.5" # Zero memory after use [workspace.dependencies.zeroize] -version = "1.8.1" +version = "1.8.2" # Password strength estimation [workspace.dependencies.zxcvbn] diff --git a/Dockerfile b/Dockerfile index 085504530..0a6ff4322 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ # syntax = docker/dockerfile:1.7.1 +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. # Builds a minimal image with the binary only. It is multi-arch capable, # cross-building to aarch64 and x86_64. When cross-compiling, Docker sets two @@ -8,10 +12,11 @@ # The Debian version and version name must be in sync ARG DEBIAN_VERSION=12 ARG DEBIAN_VERSION_NAME=bookworm -ARG RUSTC_VERSION=1.86.0 -ARG NODEJS_VERSION=20.15.0 -ARG OPA_VERSION=1.1.0 -ARG CARGO_AUDITABLE_VERSION=0.6.6 +ARG RUSTC_VERSION=1.89.0 +ARG NODEJS_VERSION=22.19.0 +# Keep in sync with .github/actions/build-policies/action.yml and policies/Makefile +ARG OPA_VERSION=1.8.0 +ARG CARGO_AUDITABLE_VERSION=0.7.0 ########################################## ## Build stage that builds the frontend ## @@ -20,7 +25,7 @@ FROM --platform=${BUILDPLATFORM} docker.io/library/node:${NODEJS_VERSION}-${DEBI WORKDIR /app/frontend -COPY ./frontend/package.json ./frontend/package-lock.json /app/frontend/ +COPY ./frontend/.npmrc ./frontend/package.json ./frontend/package-lock.json /app/frontend/ # Network access: to fetch dependencies RUN --network=default \ npm ci diff --git a/LICENSE-COMMERCIAL b/LICENSE-COMMERCIAL new file mode 100644 index 000000000..173e03e0c --- /dev/null +++ b/LICENSE-COMMERCIAL @@ -0,0 +1,6 @@ +Licensees holding a valid commercial license with Element may use this +software in accordance with the terms contained in a written agreement +between you and Element. + +To purchase a commercial license please contact our sales team at +licensing@element.io diff --git a/README.md b/README.md index 8afa6d439..8bd75b2a9 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,15 @@ Anyone can contribute to translations through [Localazy](https://localazy.com/el ## 🏗️ Contributing See the [contribution guidelines](https://element-hq.github.io/matrix-authentication-service/development/contributing.html) for information on how to contribute to this project. + +## ⚖️ Copyright & License + +Copyright 2024, 2025 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +This software is dual-licensed by New Vector Ltd (Element). It can be used either: + +(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR + +(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). +Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/biome.json b/biome.json index 8800caa30..cc5454160 100644 --- a/biome.json +++ b/biome.json @@ -1,28 +1,27 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { - "enabled": true - }, + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { - "ignore": [ - ".devcontainer/**", - "docs/**", - "translations/**", - "policies/**", - "crates/**", - "frontend/package.json", - "frontend/src/gql/**", - "frontend/src/routeTree.gen.ts", - "frontend/.storybook/locales.ts", - "frontend/.storybook/public/mockServiceWorker.js", - "frontend/locales/*.json", - "**/coverage/**", - "**/dist/**" + "includes": [ + "**", + "!**/.devcontainer/**", + "!**/docs/**", + "!**/translations/**", + "!**/policies/**", + "!**/crates/**", + "!**/frontend/package.json", + "!**/frontend/src/gql/**", + "!**/frontend/src/routeTree.gen.ts", + "!**/frontend/.storybook/locales.ts", + "!**/frontend/.storybook/public/mockServiceWorker.js", + "!**/frontend/locales/**/*.json", + "!**/coverage/**", + "!**/dist/**" ] }, "formatter": { @@ -33,9 +32,28 @@ "enabled": true, "rules": { "recommended": true, + "complexity": { + "noImportantStyles": "off" + }, + "suspicious": { + "noUnknownAtRules": "off" + }, "correctness": { "noUnusedImports": "warn", "noUnusedVariables": "warn" + }, + "style": { + "noParameterAssign": "error", + "useAsConstAssertion": "error", + "useDefaultParameterLast": "error", + "useEnumInitializers": "error", + "useSelfClosingElements": "error", + "useSingleVarDeclarator": "error", + "noUnusedTemplateLiteral": "error", + "useNumberNamespace": "error", + "noInferrableTypes": "error", + "noUselessElse": "error", + "noDescendingSpecificity": "off" } } } diff --git a/book.toml b/book.toml index e21f37eca..88f5e8263 100644 --- a/book.toml +++ b/book.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + # Documentation for possible options in this file is at # https://rust-lang.github.io/mdBook/format/config.html [book] diff --git a/clippy.toml b/clippy.toml index 3cbf7c74c..db1ba69dc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,4 +1,9 @@ -doc-valid-idents = ["OpenID", "OAuth", "..", "PostgreSQL", "SQLite"] +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + +doc-valid-idents = ["OpenID", "OAuth", "UserInfo", "..", "PostgreSQL", "SQLite"] disallowed-methods = [ { path = "rand::thread_rng", reason = "do not create rngs on the fly, pass them as parameters" }, @@ -10,7 +15,8 @@ disallowed-methods = [ ] disallowed-types = [ - "rand::OsRng", { path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" }, { path = "std::path::Path", reason = "use camino::Utf8Path instead" }, + { path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."}, + { path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"} ] diff --git a/crates/axum-utils/Cargo.toml b/crates/axum-utils/Cargo.toml index 6084984cd..ceb47a4c7 100644 --- a/crates/axum-utils/Cargo.toml +++ b/crates/axum-utils/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-axum-utils" version.workspace = true diff --git a/crates/axum-utils/src/client_authorization.rs b/crates/axum-utils/src/client_authorization.rs index 19d6c9e7a..65d885853 100644 --- a/crates/axum-utils/src/client_authorization.rs +++ b/crates/axum-utils/src/client_authorization.rs @@ -1,21 +1,20 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; use axum::{ BoxError, Json, extract::{ - Form, FromRequest, FromRequestParts, + Form, FromRequest, rejection::{FailedToDeserializeForm, FormRejection}, }, response::IntoResponse, }; -use axum_extra::typed_header::{TypedHeader, TypedHeaderRejectionReason}; -use headers::{Authorization, authorization::Basic}; +use headers::authorization::{Basic, Bearer, Credentials as _}; use http::{Request, StatusCode}; use mas_data_model::{Client, JwksOrJwksUri}; use mas_http::RequestBuilderExt; @@ -60,17 +59,30 @@ pub enum Credentials { client_id: String, jwt: Box>>, }, + BearerToken { + token: String, + }, } impl Credentials { /// Get the `client_id` of the credentials #[must_use] - pub fn client_id(&self) -> &str { + pub fn client_id(&self) -> Option<&str> { match self { Credentials::None { client_id } | Credentials::ClientSecretBasic { client_id, .. } | Credentials::ClientSecretPost { client_id, .. } - | Credentials::ClientAssertionJwtBearer { client_id, .. } => client_id, + | Credentials::ClientAssertionJwtBearer { client_id, .. } => Some(client_id), + Credentials::BearerToken { .. } => None, + } + } + + /// Get the bearer token from the credentials. + #[must_use] + pub fn bearer_token(&self) -> Option<&str> { + match self { + Credentials::BearerToken { token } => Some(token), + _ => None, } } @@ -89,6 +101,7 @@ impl Credentials { | Credentials::ClientSecretBasic { client_id, .. } | Credentials::ClientSecretPost { client_id, .. } | Credentials::ClientAssertionJwtBearer { client_id, .. } => client_id, + Credentials::BearerToken { .. } => return Ok(None), }; repo.oauth2_client().find_by_client_id(client_id).await @@ -239,7 +252,7 @@ pub struct ClientAuthorization { impl ClientAuthorization { /// Get the `client_id` from the credentials. #[must_use] - pub fn client_id(&self) -> &str { + pub fn client_id(&self) -> Option<&str> { self.credentials.client_id() } } @@ -355,31 +368,41 @@ where { type Rejection = ClientAuthorizationError; - #[allow(clippy::too_many_lines)] async fn from_request( req: Request, state: &S, ) -> Result { - // Split the request into parts so we can extract some headers - let (mut parts, body) = req.into_parts(); + enum Authorization { + Basic(String, String), + Bearer(String), + } - let header = - TypedHeader::>::from_request_parts(&mut parts, state).await; + // Sadly, the typed-header 'Authorization' doesn't let us check for both + // Basic and Bearer at the same time, so we need to parse them manually + let authorization = if let Some(header) = req.headers().get(http::header::AUTHORIZATION) { + let bytes = header.as_bytes(); + if bytes.len() >= 6 && bytes[..6].eq_ignore_ascii_case(b"Basic ") { + let Some(decoded) = Basic::decode(header) else { + return Err(ClientAuthorizationError::InvalidHeader); + }; - // Take the Authorization header - let credentials_from_header = match header { - Ok(header) => Some((header.username().to_owned(), header.password().to_owned())), - Err(err) => match err.reason() { - // If it's missing it is fine - TypedHeaderRejectionReason::Missing => None, - // If the header could not be parsed, return the error - _ => return Err(ClientAuthorizationError::InvalidHeader), - }, + Some(Authorization::Basic( + decoded.username().to_owned(), + decoded.password().to_owned(), + )) + } else if bytes.len() >= 7 && bytes[..7].eq_ignore_ascii_case(b"Bearer ") { + let Some(decoded) = Bearer::decode(header) else { + return Err(ClientAuthorizationError::InvalidHeader); + }; + + Some(Authorization::Bearer(decoded.token().to_owned())) + } else { + return Err(ClientAuthorizationError::InvalidHeader); + } + } else { + None }; - // Reconstruct the request from the parts - let req = Request::from_parts(parts, body); - // Take the form value let ( client_id_from_form, @@ -407,13 +430,19 @@ where // And now, figure out the actual auth method let credentials = match ( - credentials_from_header, + authorization, client_id_from_form, client_secret_from_form, client_assertion_type, client_assertion, ) { - (Some((client_id, client_secret)), client_id_from_form, None, None, None) => { + ( + Some(Authorization::Basic(client_id, client_secret)), + client_id_from_form, + None, + None, + None, + ) => { if let Some(client_id_from_form) = client_id_from_form { // If the client_id was in the body, verify it matches with the header if client_id != client_id_from_form { @@ -483,6 +512,11 @@ where }); } + (Some(Authorization::Bearer(token)), None, None, None, None) => { + // Got a bearer token + Credentials::BearerToken { token } + } + (None, None, None, None, None) => { // Special case when there are no credentials anywhere return Err(ClientAuthorizationError::MissingCredentials); @@ -677,4 +711,29 @@ mod tests { jwt.verify_with_shared_secret(b"client-secret".to_vec()) .unwrap(); } + + #[tokio::test] + async fn bearer_token_test() { + let req = Request::builder() + .method(Method::POST) + .header( + http::header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .header(http::header::AUTHORIZATION, "Bearer token") + .body(Body::new("foo=bar".to_owned())) + .unwrap(); + + assert_eq!( + ClientAuthorization::::from_request(req, &()) + .await + .unwrap(), + ClientAuthorization { + credentials: Credentials::BearerToken { + token: "token".to_owned(), + }, + form: Some(serde_json::json!({"foo": "bar"})), + } + ); + } } diff --git a/crates/axum-utils/src/cookies.rs b/crates/axum-utils/src/cookies.rs index c3572a266..97f1db830 100644 --- a/crates/axum-utils/src/cookies.rs +++ b/crates/axum-utils/src/cookies.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Private (encrypted) cookie jar, based on axum-extra's cookie jar diff --git a/crates/axum-utils/src/csrf.rs b/crates/axum-utils/src/csrf.rs index bf94e4ce9..a4ef49b76 100644 --- a/crates/axum-utils/src/csrf.rs +++ b/crates/axum-utils/src/csrf.rs @@ -1,12 +1,12 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use base64ct::{Base64UrlUnpadded, Encoding}; use chrono::{DateTime, Duration, Utc}; -use mas_storage::Clock; +use mas_data_model::Clock; use rand::{Rng, RngCore, distributions::Standard, prelude::Distribution as _}; use serde::{Deserialize, Serialize}; use serde_with::{TimestampSeconds, serde_as}; diff --git a/crates/axum-utils/src/error_wrapper.rs b/crates/axum-utils/src/error_wrapper.rs index 2bfd448fc..0865768d6 100644 --- a/crates/axum-utils/src/error_wrapper.rs +++ b/crates/axum-utils/src/error_wrapper.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::response::{IntoResponse, Response}; diff --git a/crates/axum-utils/src/fancy_error.rs b/crates/axum-utils/src/fancy_error.rs index 98c2a3c51..cb6d4e5eb 100644 --- a/crates/axum-utils/src/fancy_error.rs +++ b/crates/axum-utils/src/fancy_error.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ Extension, diff --git a/crates/axum-utils/src/jwt.rs b/crates/axum-utils/src/jwt.rs index 14e966c0d..f7747828c 100644 --- a/crates/axum-utils/src/jwt.rs +++ b/crates/axum-utils/src/jwt.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::response::{IntoResponse, Response}; use axum_extra::typed_header::TypedHeader; diff --git a/crates/axum-utils/src/language_detection.rs b/crates/axum-utils/src/language_detection.rs index efb148f74..057453a0a 100644 --- a/crates/axum-utils/src/language_detection.rs +++ b/crates/axum-utils/src/language_detection.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::cmp::Reverse; diff --git a/crates/axum-utils/src/lib.rs b/crates/axum-utils/src/lib.rs index a3dc31cca..a4e769dcd 100644 --- a/crates/axum-utils/src/lib.rs +++ b/crates/axum-utils/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(clippy::future_not_send)] #![allow(clippy::module_name_repetitions)] diff --git a/crates/axum-utils/src/sentry.rs b/crates/axum-utils/src/sentry.rs index 2744accff..9cf26301e 100644 --- a/crates/axum-utils/src/sentry.rs +++ b/crates/axum-utils/src/sentry.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::convert::Infallible; diff --git a/crates/axum-utils/src/session.rs b/crates/axum-utils/src/session.rs index 98cbd4865..b5ed670ae 100644 --- a/crates/axum-utils/src/session.rs +++ b/crates/axum-utils/src/session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_data_model::BrowserSession; use mas_storage::RepositoryAccess; diff --git a/crates/axum-utils/src/user_authorization.rs b/crates/axum-utils/src/user_authorization.rs index 52e901c5c..395ce465a 100644 --- a/crates/axum-utils/src/user_authorization.rs +++ b/crates/axum-utils/src/user_authorization.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, error::Error}; @@ -16,9 +16,9 @@ use axum::{ use axum_extra::typed_header::{TypedHeader, TypedHeaderRejectionReason}; use headers::{Authorization, Header, HeaderMapExt, HeaderName, authorization::Bearer}; use http::{HeaderMap, HeaderValue, Request, StatusCode, header::WWW_AUTHENTICATE}; -use mas_data_model::Session; +use mas_data_model::{Clock, Session}; use mas_storage::{ - Clock, RepositoryAccess, + RepositoryAccess, oauth2::{OAuth2AccessTokenRepository, OAuth2SessionRepository}, }; use serde::{Deserialize, de::DeserializeOwned}; diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 40c46897a..1baebd12c 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-cli" version.workspace = true @@ -54,12 +59,11 @@ opentelemetry.workspace = true opentelemetry-http.workspace = true opentelemetry-jaeger-propagator.workspace = true opentelemetry-otlp.workspace = true -opentelemetry-prometheus.workspace = true +opentelemetry-prometheus-text-exporter.workspace = true opentelemetry-resource-detectors.workspace = true opentelemetry-semantic-conventions.workspace = true opentelemetry-stdout.workspace = true opentelemetry_sdk.workspace = true -prometheus.workspace = true sentry.workspace = true sentry-tracing.workspace = true sentry-tower.workspace = true diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 2615b1284..fd111273b 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use vergen_gitcl::{Emitter, GitclBuilder, RustcBuilder}; @@ -12,13 +12,13 @@ fn main() -> anyhow::Result<()> { // At build time, we override the version through the environment variable // VERGEN_GIT_DESCRIBE. In some contexts, it means this variable is set but // empty, so we unset it here. - if let Ok(ver) = std::env::var("VERGEN_GIT_DESCRIBE") { - if ver.is_empty() { - #[allow(unsafe_code)] - // SAFETY: This is safe because the build script is running a single thread - unsafe { - std::env::remove_var("VERGEN_GIT_DESCRIBE"); - } + if let Ok(ver) = std::env::var("VERGEN_GIT_DESCRIBE") + && ver.is_empty() + { + #[allow(unsafe_code)] + // SAFETY: This is safe because the build script is running a single thread + unsafe { + std::env::remove_var("VERGEN_GIT_DESCRIBE"); } } diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 55b592aea..f211fc29c 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{convert::Infallible, net::IpAddr, sync::Arc}; use axum::extract::{FromRef, FromRequestParts}; use ipnetwork::IpNetwork; use mas_context::LogContext; -use mas_data_model::SiteConfig; +use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, SystemClock}; use mas_handlers::{ ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter, MetadataCache, RequesterFingerprint, passwords::PasswordManager, @@ -19,9 +19,7 @@ use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_policy::{Policy, PolicyFactory}; use mas_router::UrlBuilder; -use mas_storage::{ - BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, RepositoryFactory, SystemClock, -}; +use mas_storage::{BoxRepository, BoxRepositoryFactory, RepositoryFactory}; use mas_storage_pg::PgRepositoryFactory; use mas_templates::Templates; use opentelemetry::KeyValue; @@ -29,7 +27,7 @@ use rand::SeedableRng; use sqlx::PgPool; use tracing::Instrument; -use crate::telemetry::METER; +use crate::{VERSION, telemetry::METER}; #[derive(Clone)] pub struct AppState { @@ -216,6 +214,12 @@ impl FromRef for Arc { } } +impl FromRef for AppVersion { + fn from_ref(_input: &AppState) -> Self { + AppVersion(VERSION) + } +} + impl FromRequestParts for BoxClock { type Rejection = Infallible; @@ -275,10 +279,10 @@ fn infer_client_ip( let peer = if let Some(info) = connection_info { // We can always trust the proxy protocol to give us the correct IP address - if let Some(proxy) = info.get_proxy_ref() { - if let Some(source) = proxy.source() { - return Some(source.ip()); - } + if let Some(proxy) = info.get_proxy_ref() + && let Some(source) = proxy.source() + { + return Some(source.ip()); } info.get_peer_addr().map(|addr| addr.ip()) diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs index 00600026c..034f84b4a 100644 --- a/crates/cli/src/commands/config.rs +++ b/crates/cli/src/commands/config.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::process::ExitCode; @@ -11,7 +11,7 @@ use camino::Utf8PathBuf; use clap::Parser; use figment::Figment; use mas_config::{ConfigurationSection, RootConfig, SyncConfig}; -use mas_storage::{Clock as _, SystemClock}; +use mas_data_model::{Clock as _, SystemClock}; use mas_storage_pg::MIGRATOR; use rand::SeedableRng; use tokio::io::AsyncWriteExt; @@ -72,7 +72,7 @@ impl Options { SC::Dump { output } => { let _span = info_span!("cli.config.dump").entered(); - let config = RootConfig::extract(figment)?; + let config = RootConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; let config = serde_yaml::to_string(&config)?; if let Some(output) = output { @@ -88,7 +88,7 @@ impl Options { SC::Check => { let _span = info_span!("cli.config.check").entered(); - let _config = RootConfig::extract(figment)?; + let _config = RootConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; info!("Configuration file looks good"); } @@ -105,7 +105,8 @@ impl Options { if !synapse_config.is_empty() { info!("Adjusting MAS config to match Synapse config from {synapse_config:?}"); - let synapse_config = syn2mas::synapse_config::Config::load(&synapse_config)?; + let synapse_config = syn2mas::synapse_config::Config::load(&synapse_config) + .map_err(anyhow::Error::from_boxed)?; config = synapse_config.adjust_mas_config(config, &mut rng, clock.now()); } @@ -121,7 +122,7 @@ impl Options { } SC::Sync { prune, dry_run } => { - let config = SyncConfig::extract(figment)?; + let config = SyncConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; let clock = SystemClock::default(); let encrypter = config.secrets.encrypter().await?; diff --git a/crates/cli/src/commands/database.rs b/crates/cli/src/commands/database.rs index 0283e5049..519536fff 100644 --- a/crates/cli/src/commands/database.rs +++ b/crates/cli/src/commands/database.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::process::ExitCode; @@ -30,7 +30,8 @@ enum Subcommand { impl Options { pub async fn run(self, figment: &Figment) -> anyhow::Result { let _span = info_span!("cli.database.migrate").entered(); - let config = DatabaseConfig::extract_or_default(figment)?; + let config = + DatabaseConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&config).await?; // Run pending migrations diff --git a/crates/cli/src/commands/debug.rs b/crates/cli/src/commands/debug.rs index 2c004974f..bb87c5e81 100644 --- a/crates/cli/src/commands/debug.rs +++ b/crates/cli/src/commands/debug.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::process::ExitCode; @@ -41,13 +41,16 @@ impl Options { match self.subcommand { SC::Policy { with_dynamic_data } => { let _span = info_span!("cli.debug.policy").entered(); - let config = PolicyConfig::extract_or_default(figment)?; - let matrix_config = MatrixConfig::extract(figment)?; + let config = + PolicyConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; + let matrix_config = + MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; info!("Loading and compiling the policy module"); let policy_factory = policy_factory_from_config(&config, &matrix_config).await?; if with_dynamic_data { - let database_config = DatabaseConfig::extract(figment)?; + let database_config = + DatabaseConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; let pool = database_pool_from_config(&database_config).await?; let repository_factory = PgRepositoryFactory::new(pool.clone()); load_policy_factory_dynamic_data(&policy_factory, &repository_factory).await?; diff --git a/crates/cli/src/commands/doctor.rs b/crates/cli/src/commands/doctor.rs index 28ab4e919..636741487 100644 --- a/crates/cli/src/commands/doctor.rs +++ b/crates/cli/src/commands/doctor.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Diagnostic utility to check the health of the deployment //! @@ -14,6 +14,7 @@ use std::process::ExitCode; use anyhow::Context; use clap::Parser; use figment::Figment; +use hyper::StatusCode; use mas_config::{ConfigurationSection, RootConfig}; use mas_http::RequestBuilderExt; use tracing::{error, info, info_span, warn}; @@ -26,14 +27,13 @@ const DOCS_BASE: &str = "https://element-hq.github.io/matrix-authentication-serv pub(super) struct Options {} impl Options { - #[allow(clippy::too_many_lines)] pub async fn run(self, figment: &Figment) -> anyhow::Result { let _span = info_span!("cli.doctor").entered(); info!( "💡 Running diagnostics, make sure that both MAS and Synapse are running, and that MAS is using the same configuration files as this tool." ); - let config = RootConfig::extract(figment)?; + let config = RootConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; // We'll need an HTTP client let http_client = mas_http::reqwest_client(); @@ -44,8 +44,8 @@ impl Options { r"The homeserver host in the config (`matrix.homeserver`) is not a valid domain. See {DOCS_BASE}/setup/homeserver.html", )?; + let secret = config.matrix.secret().await?; let hs_api = config.matrix.endpoint; - let admin_token = config.matrix.secret; if !issuer.starts_with("https://") { warn!( @@ -100,17 +100,14 @@ Make sure that the MAS config contains: http: public_base: {issuer:?} - # Or, if the issuer is different from the public base: - issuer: {issuer:?} And in the Synapse config: - experimental_features: - msc3861: - enabled: true - # This must exactly match: - issuer: {issuer:?} - # ... + matrix_authentication_service: + enabled: true + # This must point to where MAS is reachable by Synapse + endpoint: {issuer:?} + # ... See {DOCS_BASE}/setup/homeserver.html "# @@ -129,11 +126,10 @@ Check the well-known document at "{well_known_uri}" Check the well-known document at "{well_known_uri}" Make sure Synapse has delegated auth enabled: - experimental_features: - msc3861: - enabled: true - issuer: {issuer:?} - # ... + matrix_authentication_service: + enabled: true + endpoint: {issuer:?} + # ... If it is not Synapse handling the well-known document, update it to include the following: @@ -284,70 +280,50 @@ Error details: {e} ), } - // Try to reach the admin API on an unauthorized endpoint - let server_version = hs_api.join("/_synapse/admin/v1/server_version")?; - let result = http_client.get(server_version.as_str()).send_traced().await; - match result { - Ok(response) => { - let status = response.status(); - if status.is_success() { - info!(r#"✅ The Synapse admin API is reachable at "{server_version}"."#); - } else { - error!( - r#"❌ A Synapse admin API endpoint at "{server_version}" replied with {status}. -Make sure MAS can reach the admin API, and that the homeserver is running. -"# - ); - } - } - Err(e) => error!( - r#"❌ Can't reach the Synapse admin API at "{server_version}". -Make sure MAS can reach the admin API, and that the homeserver is running. - -Error details: {e} -"# - ), - } - - // Try to reach an authenticated admin API endpoint - let background_updates = hs_api.join("/_synapse/admin/v1/background_updates/status")?; + // Try to reach an authenticated MAS API endpoint + let mas_api = hs_api.join("/_synapse/mas/is_localpart_available")?; let result = http_client - .get(background_updates.as_str()) - .bearer_auth(&admin_token) + .get(mas_api.as_str()) + .bearer_auth(&secret) .send_traced() .await; match result { Ok(response) => { let status = response.status(); - if status.is_success() { + // We intentionally omit the required 'localpart' parameter + // in this request. If authentication is successful, Synapse + // returns a 400 Bad Request because of the missing + // parameter. If authentication fails, Synapse will return a + // 403 Forbidden. If the MAS integration isn't enabled, + // Synapse will return a 404 Not found. + if status == StatusCode::BAD_REQUEST { info!( - r#"✅ The Synapse admin API is reachable with authentication at "{background_updates}"."# + r#"✅ The Synapse MAS API is reachable with authentication at "{mas_api}"."# ); } else { error!( - r#"❌ A Synapse admin API endpoint at "{background_updates}" replied with {status}. + r#"❌ A Synapse MAS API endpoint at "{mas_api}" replied with {status}. Make sure the homeserver is running, and that the MAS config has the correct `matrix.secret`. -It should match the `admin_token` set in the Synapse config. +It should match the `secret` set in the Synapse config. - experimental_features: - msc3861: - enabled: true - issuer: {issuer} - # This must exactly match the secret in the MAS config: - admin_token: {admin_token:?} + matrix_authentication_service: + enabled: true + endpoint: {issuer:?} + # This must exactly match the secret in the MAS config: + secret: {secret:?} And in the MAS config: matrix: homeserver: "{matrix_domain}" endpoint: "{hs_api}" - secret: {admin_token:?} + secret: {secret:?} "# ); } } Err(e) => error!( - r#"❌ Can't reach the Synapse admin API at "{background_updates}". + r#"❌ Can't reach the Synapse MAS API at "{mas_api}". Make sure the homeserver is running, and that the MAS config has the correct `matrix.secret`. Error details: {e} diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index d2f13f0c5..5bbd870ea 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::BTreeMap, process::ExitCode}; @@ -15,18 +15,21 @@ use figment::Figment; use mas_config::{ ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PasswordsConfig, }; -use mas_data_model::{Device, TokenType, Ulid, UpstreamOAuthProvider, User}; +use mas_data_model::{Clock, Device, SystemClock, TokenType, Ulid, UpstreamOAuthProvider, User}; use mas_email::Address; use mas_matrix::HomeserverConnection; use mas_storage::{ - Clock, RepositoryAccess, SystemClock, + Pagination, RepositoryAccess, compat::{CompatAccessTokenRepository, CompatSessionFilter, CompatSessionRepository}, oauth2::OAuth2SessionFilter, queue::{ DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _, ReactivateUserJob, SyncDevicesJob, }, - user::{BrowserSessionFilter, UserEmailRepository, UserPasswordRepository, UserRepository}, + user::{ + BrowserSessionFilter, UserEmailRepository, UserFilter, UserPasswordRepository, + UserRepository, + }, }; use mas_storage_pg::{DatabaseError, PgRepository}; use rand::{ @@ -85,6 +88,15 @@ enum Subcommand { ignore_complexity: bool, }, + /// Make a user admin + PromoteAdmin { username: String }, + + /// Make a user non-admin + DemoteAdmin { username: String }, + + /// List all users with admin privileges + ListAdminUsers, + /// Issue a compatibility token IssueCompatibilityToken { /// User for which to issue the token @@ -149,6 +161,10 @@ enum Subcommand { UnlockUser { /// User to unlock username: String, + + /// Whether to reactivate the user if it had been deactivated + #[arg(long)] + reactivate: bool, }, /// Register a user @@ -203,7 +219,6 @@ enum Subcommand { } impl Options { - #[allow(clippy::too_many_lines)] pub async fn run(self, figment: &Figment) -> anyhow::Result { use Subcommand as SC; let clock = SystemClock::default(); @@ -219,8 +234,10 @@ impl Options { let _span = info_span!("cli.manage.set_password", user.username = %username).entered(); - let database_config = DatabaseConfig::extract_or_default(figment)?; - let passwords_config = PasswordsConfig::extract_or_default(figment)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let passwords_config = PasswordsConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&database_config).await?; let password_manager = password_manager_from_config(&passwords_config).await?; @@ -260,7 +277,8 @@ impl Options { ) .entered(); - let database_config = DatabaseConfig::extract_or_default(figment)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&database_config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); @@ -309,12 +327,95 @@ impl Options { Ok(ExitCode::SUCCESS) } + SC::PromoteAdmin { username } => { + let _span = + info_span!("cli.manage.promote_admin", user.username = username,).entered(); + + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let mut conn = database_connection_from_config(&database_config).await?; + let txn = conn.begin().await?; + let mut repo = PgRepository::from_conn(txn); + + let user = repo + .user() + .find_by_username(&username) + .await? + .context("User not found")?; + + let user = repo.user().set_can_request_admin(user, true).await?; + + repo.into_inner().commit().await?; + info!(%user.id, %user.username, "User promoted to admin"); + + Ok(ExitCode::SUCCESS) + } + + SC::DemoteAdmin { username } => { + let _span = + info_span!("cli.manage.demote_admin", user.username = username,).entered(); + + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let mut conn = database_connection_from_config(&database_config).await?; + let txn = conn.begin().await?; + let mut repo = PgRepository::from_conn(txn); + + let user = repo + .user() + .find_by_username(&username) + .await? + .context("User not found")?; + + let user = repo.user().set_can_request_admin(user, false).await?; + + repo.into_inner().commit().await?; + info!(%user.id, %user.username, "User is no longer admin"); + + Ok(ExitCode::SUCCESS) + } + + SC::ListAdminUsers => { + let _span = info_span!("cli.manage.list_admins").entered(); + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let mut conn = database_connection_from_config(&database_config).await?; + let txn = conn.begin().await?; + let mut repo = PgRepository::from_conn(txn); + + let mut cursor = Pagination::first(1000); + let filter = UserFilter::new().can_request_admin_only(); + let total = repo.user().count(filter).await?; + + info!("The following users can request admin privileges ({total} total):"); + loop { + let page = repo.user().list(filter, cursor).await?; + for edge in page.edges { + let user = edge.node; + info!(%user.id, username = %user.username); + cursor = cursor.after(edge.cursor); + } + + if !page.has_next_page { + break; + } + } + + Ok(ExitCode::SUCCESS) + } + SC::IssueCompatibilityToken { username, admin, device_id, } => { - let database_config = DatabaseConfig::extract_or_default(figment)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let matrix_config = + MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; + let http_client = mas_http::reqwest_client(); + let homeserver = + homeserver_connection_from_config(&matrix_config, http_client).await?; let mut conn = database_connection_from_config(&database_config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); @@ -331,6 +432,24 @@ impl Options { Device::generate(&mut rng) }; + if let Err(e) = homeserver + .upsert_device(&user.username, device.as_str(), None) + .await + { + error!( + error = &*e, + "Could not create the device on the homeserver, aborting" + ); + + // Schedule a device sync job to remove the potential leftover device + repo.queue_job() + .schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user)) + .await?; + + repo.into_inner().commit().await?; + return Ok(ExitCode::FAILURE); + } + let compat_session = repo .compat_session() .add(&mut rng, &clock, &user, device, None, admin, None) @@ -372,7 +491,8 @@ impl Options { (Some(_), true) => unreachable!(), // This should be handled by the clap group }; - let database_config = DatabaseConfig::extract_or_default(figment)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&database_config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); @@ -399,7 +519,8 @@ impl Options { SC::ProvisionAllUsers => { let _span = info_span!("cli.manage.provision_all_users").entered(); - let database_config = DatabaseConfig::extract_or_default(figment)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&database_config).await?; let mut txn = conn.begin().await?; @@ -425,7 +546,8 @@ impl Options { SC::KillSessions { username, dry_run } => { let _span = info_span!("cli.manage.kill_sessions", user.username = username).entered(); - let database_config = DatabaseConfig::extract_or_default(figment)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&database_config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); @@ -497,7 +619,8 @@ impl Options { deactivate, } => { let _span = info_span!("cli.manage.lock_user", user.username = username).entered(); - let config = DatabaseConfig::extract_or_default(figment)?; + let config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); @@ -527,9 +650,14 @@ impl Options { Ok(ExitCode::SUCCESS) } - SC::UnlockUser { username } => { - let _span = info_span!("cli.manage.lock_user", user.username = username).entered(); - let config = DatabaseConfig::extract_or_default(figment)?; + SC::UnlockUser { + username, + reactivate, + } => { + let _span = + info_span!("cli.manage.unlock_user", user.username = username).entered(); + let config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let mut conn = database_connection_from_config(&config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); @@ -540,10 +668,14 @@ impl Options { .await? .context("User not found")?; - warn!(%user.id, "User scheduling user reactivation"); - repo.queue_job() - .schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user)) - .await?; + if reactivate { + warn!(%user.id, "Scheduling user reactivation"); + repo.queue_job() + .schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user)) + .await?; + } else { + repo.user().unlock(user).await?; + } repo.into_inner().commit().await?; @@ -562,23 +694,26 @@ impl Options { ignore_password_complexity, } => { let http_client = mas_http::reqwest_client(); - let password_config = PasswordsConfig::extract_or_default(figment)?; - let database_config = DatabaseConfig::extract_or_default(figment)?; - let matrix_config = MatrixConfig::extract(figment)?; + let password_config = PasswordsConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let database_config = DatabaseConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let matrix_config = + MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; let password_manager = password_manager_from_config(&password_config).await?; - let homeserver = homeserver_connection_from_config(&matrix_config, http_client); + let homeserver = + homeserver_connection_from_config(&matrix_config, http_client).await?; let mut conn = database_connection_from_config(&database_config).await?; let txn = conn.begin().await?; let mut repo = PgRepository::from_conn(txn); - if let Some(password) = &password { - if !ignore_password_complexity - && !password_manager.is_password_complex_enough(password)? - { - error!("That password is too weak."); - return Ok(ExitCode::from(1)); - } + if let Some(password) = &password + && !ignore_password_complexity + && !password_manager.is_password_complex_enough(password)? + { + error!("That password is too weak."); + return Ok(ExitCode::from(1)); } // If the username is provided, check if it's available and normalize it. diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 9f0938083..e8cef3b1f 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::process::ExitCode; diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index dcdbca0d3..52465f077 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::BTreeSet, process::ExitCode, sync::Arc, time::Duration}; @@ -14,10 +14,10 @@ use mas_config::{ AppConfig, ClientsConfig, ConfigurationSection, ConfigurationSectionExt, UpstreamOAuth2Config, }; use mas_context::LogContext; +use mas_data_model::SystemClock; use mas_handlers::{ActivityTracker, CookieManager, Limiter, MetadataCache}; use mas_listener::server::Server; use mas_router::UrlBuilder; -use mas_storage::SystemClock; use mas_storage_pg::{MIGRATOR, PgRepositoryFactory}; use sqlx::migrate::Migrate; use tracing::{Instrument, info, info_span, warn}; @@ -55,11 +55,10 @@ pub(super) struct Options { } impl Options { - #[allow(clippy::too_many_lines)] pub async fn run(self, figment: &Figment) -> anyhow::Result { let span = info_span!("cli.run.init").entered(); let mut shutdown = LifecycleManager::new()?; - let config = AppConfig::extract(figment)?; + let config = AppConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; info!(version = crate::VERSION, "Starting up"); @@ -101,8 +100,10 @@ impl Options { } else { // Sync the configuration with the database let mut conn = pool.acquire().await?; - let clients_config = ClientsConfig::extract_or_default(figment)?; - let upstream_oauth2_config = UpstreamOAuth2Config::extract_or_default(figment)?; + let clients_config = + ClientsConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; + let upstream_oauth2_config = UpstreamOAuth2Config::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; crate::sync::config_sync( upstream_oauth2_config, @@ -159,22 +160,29 @@ impl Options { )?; // Load and compile the templates - let templates = - templates_from_config(&config.templates, &site_config, &url_builder).await?; + let templates = templates_from_config( + &config.templates, + &site_config, + &url_builder, + // Don't use strict mode in production yet + false, + ) + .await?; shutdown.register_reloadable(&templates); let http_client = mas_http::reqwest_client(); let homeserver_connection = - homeserver_connection_from_config(&config.matrix, http_client.clone()); + homeserver_connection_from_config(&config.matrix, http_client.clone()).await?; if !self.no_worker { let mailer = mailer_from_config(&config.email, &templates)?; test_mailer_in_background(&mailer, Duration::from_secs(30)); info!("Starting task worker"); - mas_tasks::init( + mas_tasks::init_and_run( PgRepositoryFactory::new(pool.clone()), + SystemClock::default(), &mailer, homeserver_connection.clone(), url_builder.clone(), diff --git a/crates/cli/src/commands/syn2mas.rs b/crates/cli/src/commands/syn2mas.rs index 22194953e..c28935af5 100644 --- a/crates/cli/src/commands/syn2mas.rs +++ b/crates/cli/src/commands/syn2mas.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, process::ExitCode, time::Duration}; @@ -13,7 +13,7 @@ use mas_config::{ ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, SyncConfig, UpstreamOAuth2Config, }; -use mas_storage::SystemClock; +use mas_data_model::SystemClock; use mas_storage_pg::MIGRATOR; use rand::thread_rng; use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types::Uuid}; @@ -88,7 +88,6 @@ const NUM_WRITER_CONNECTIONS: usize = 8; impl Options { #[tracing::instrument("cli.syn2mas.run", skip_all)] - #[allow(clippy::too_many_lines)] pub async fn run(self, figment: &Figment) -> anyhow::Result { if self.synapse_configuration_files.is_empty() { error!("Please specify the path to the Synapse configuration file(s)."); @@ -96,6 +95,7 @@ impl Options { } let synapse_config = synapse_config::Config::load(&self.synapse_configuration_files) + .map_err(anyhow::Error::from_boxed) .context("Failed to load Synapse configuration")?; // Establish a connection to Synapse's Postgres database @@ -111,7 +111,8 @@ impl Options { .await .context("could not connect to Synapse Postgres database")?; - let config = DatabaseConfig::extract_or_default(figment)?; + let config = + DatabaseConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; let mut mas_connection = database_connection_from_config_with_options( &config, @@ -131,7 +132,7 @@ impl Options { // First perform a config sync // This is crucial to ensure we register upstream OAuth providers // in the MAS database - let config = SyncConfig::extract(figment)?; + let config = SyncConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; let clock = SystemClock::default(); let encrypter = config.secrets.encrypter().await?; @@ -213,7 +214,8 @@ impl Options { Subcommand::Migrate { dry_run } => { let provider_id_mappings: HashMap = { - let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment)?; + let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; mas_oauth2 .providers @@ -252,7 +254,8 @@ impl Options { let occasional_progress_logger_task = tokio::spawn(occasional_progress_logger(progress.clone())); - let mas_matrix = MatrixConfig::extract(figment)?; + let mas_matrix = + MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; syn2mas::migrate( reader, writer, diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index 153565df7..8f1b0dd4e 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -1,18 +1,20 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -use std::process::ExitCode; +use std::{fmt::Write, process::ExitCode}; +use anyhow::{Context as _, bail}; +use camino::Utf8PathBuf; use clap::Parser; use figment::Figment; use mas_config::{ AccountConfig, BrandingConfig, CaptchaConfig, ConfigurationSection, ConfigurationSectionExt, ExperimentalConfig, MatrixConfig, PasswordsConfig, TemplatesConfig, }; -use mas_storage::{Clock, SystemClock}; +use mas_data_model::{Clock, SystemClock}; use rand::SeedableRng; use tracing::info_span; @@ -27,23 +29,35 @@ pub(super) struct Options { #[derive(Parser, Debug)] enum Subcommand { /// Check that the templates specified in the config are valid - Check, + Check { + /// If set, templates will be rendered to this directory. + /// The directory must either not exist or be empty. + #[arg(long = "out-dir")] + out_dir: Option, + }, } impl Options { pub async fn run(self, figment: &Figment) -> anyhow::Result { use Subcommand as SC; match self.subcommand { - SC::Check => { + SC::Check { out_dir } => { let _span = info_span!("cli.templates.check").entered(); - let template_config = TemplatesConfig::extract_or_default(figment)?; - let branding_config = BrandingConfig::extract_or_default(figment)?; - let matrix_config = MatrixConfig::extract(figment)?; - let experimental_config = ExperimentalConfig::extract_or_default(figment)?; - let password_config = PasswordsConfig::extract_or_default(figment)?; - let account_config = AccountConfig::extract_or_default(figment)?; - let captcha_config = CaptchaConfig::extract_or_default(figment)?; + let template_config = TemplatesConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let branding_config = BrandingConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let matrix_config = + MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; + let experimental_config = ExperimentalConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let password_config = PasswordsConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let account_config = AccountConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; + let captcha_config = CaptchaConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let clock = SystemClock::default(); // XXX: we should disallow SeedableRng::from_entropy @@ -58,9 +72,54 @@ impl Options { &account_config, &captcha_config, )?; - let templates = - templates_from_config(&template_config, &site_config, &url_builder).await?; - templates.check_render(clock.now(), &mut rng)?; + let templates = templates_from_config( + &template_config, + &site_config, + &url_builder, // Use strict mode in template checks + true, + ) + .await?; + let all_renders = templates.check_render(clock.now(), &mut rng)?; + + if let Some(out_dir) = out_dir { + // Save renders to disk. + if out_dir.exists() { + let mut read_dir = + tokio::fs::read_dir(&out_dir).await.with_context(|| { + format!("could not read {out_dir} to check it's empty") + })?; + if read_dir.next_entry().await?.is_some() { + bail!("Render directory {out_dir} is not empty, refusing to write."); + } + } else { + tokio::fs::create_dir(&out_dir) + .await + .with_context(|| format!("could not create {out_dir}"))?; + } + + for ((template, sample_identifier), template_render) in &all_renders { + let (template_filename_base, template_ext) = + template.rsplit_once('.').unwrap_or((template, "txt")); + let template_filename_base = template_filename_base.replace('/', "_"); + + // Make a string like `-index=0-browser-session=0-locale=fr` + let sample_suffix = { + let mut s = String::new(); + for (k, v) in &sample_identifier.components { + write!(s, "-{k}={v}")?; + } + s + }; + + let render_path = out_dir.join(format!( + "{template_filename_base}{sample_suffix}.{template_ext}" + )); + + tokio::fs::write(&render_path, template_render.as_bytes()) + .await + .with_context(|| format!("could not write render to {render_path}"))?; + } + } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index f13a1ae3c..a1eb0fcce 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -1,14 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{process::ExitCode, time::Duration}; use clap::Parser; use figment::Figment; use mas_config::{AppConfig, ConfigurationSection}; +use mas_data_model::SystemClock; use mas_router::UrlBuilder; use mas_storage_pg::PgRepositoryFactory; use tracing::{info, info_span}; @@ -28,7 +29,7 @@ impl Options { pub async fn run(self, figment: &Figment) -> anyhow::Result { let shutdown = LifecycleManager::new()?; let span = info_span!("cli.worker.init").entered(); - let config = AppConfig::extract(figment)?; + let config = AppConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; // Connect to the database info!("Connecting to the database"); @@ -51,20 +52,27 @@ impl Options { )?; // Load and compile the templates - let templates = - templates_from_config(&config.templates, &site_config, &url_builder).await?; + let templates = templates_from_config( + &config.templates, + &site_config, + &url_builder, + // Don't use strict mode on task workers for now + false, + ) + .await?; let mailer = mailer_from_config(&config.email, &templates)?; test_mailer_in_background(&mailer, Duration::from_secs(30)); let http_client = mas_http::reqwest_client(); - let conn = homeserver_connection_from_config(&config.matrix, http_client); + let conn = homeserver_connection_from_config(&config.matrix, http_client).await?; drop(config); info!("Starting task scheduler"); - mas_tasks::init( + mas_tasks::init_and_run( PgRepositoryFactory::new(pool.clone()), + SystemClock::default(), &mailer, conn, url_builder, diff --git a/crates/cli/src/lifecycle.rs b/crates/cli/src/lifecycle.rs index 4a2c429d1..e44162936 100644 --- a/crates/cli/src/lifecycle.rs +++ b/crates/cli/src/lifecycle.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{process::ExitCode, time::Duration}; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f0da47c09..9c1121cca 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::module_name_repetitions)] @@ -115,8 +115,9 @@ async fn try_main() -> anyhow::Result { // Load the base configuration files let figment = opts.figment(); - let telemetry_config = - TelemetryConfig::extract_or_default(&figment).context("Failed to load telemetry config")?; + let telemetry_config = TelemetryConfig::extract_or_default(&figment) + .map_err(anyhow::Error::from_boxed) + .context("Failed to load telemetry config")?; // Setup Sentry let sentry = sentry::init(( @@ -127,8 +128,6 @@ async fn try_main() -> anyhow::Result { release: Some(VERSION.into()), sample_rate: telemetry_config.sentry.sample_rate.unwrap_or(1.0), traces_sample_rate: telemetry_config.sentry.traces_sample_rate.unwrap_or(0.0), - auto_session_tracking: true, - session_mode: sentry::SessionMode::Request, ..Default::default() }, )); @@ -150,12 +149,14 @@ async fn try_main() -> anyhow::Result { // Setup OpenTelemetry tracing and metrics self::telemetry::setup(&telemetry_config).context("failed to setup OpenTelemetry")?; - let telemetry_layer = self::telemetry::TRACER.get().map(|tracer| { - tracing_opentelemetry::layer() - .with_tracer(tracer.clone()) - .with_tracked_inactivity(false) - .with_filter(LevelFilter::INFO) - }); + let tracer = self::telemetry::TRACER + .get() + .context("TRACER was not set")?; + + let telemetry_layer = tracing_opentelemetry::layer() + .with_tracer(tracer.clone()) + .with_tracked_inactivity(false) + .with_filter(LevelFilter::INFO); let subscriber = Registry::default() .with(suppress_layer) diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index eefd7211c..4279e7e4a 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, ToSocketAddrs}, @@ -143,7 +143,12 @@ fn make_http_span(req: &Request) -> Span { propagator.extract_with_context(&context, &extractor) }); - span.set_parent(parent_context); + if let Err(err) = span.set_parent(parent_context) { + tracing::error!( + error = &err as &dyn std::error::Error, + "Failed to set parent context on span" + ); + } span } @@ -205,7 +210,6 @@ async fn log_response_middleware( response } -#[allow(clippy::too_many_lines)] pub fn build_router( state: AppState, resources: &[HttpResource], @@ -269,7 +273,7 @@ pub fn build_router( } mas_config::HttpResource::OAuth => router.merge(mas_handlers::api_router::()), mas_config::HttpResource::Compat => { - router.merge(mas_handlers::compat_router::()) + router.merge(mas_handlers::compat_router::(templates.clone())) } mas_config::HttpResource::AdminApi => { let (_, api_router) = mas_handlers::admin_api_router::(); @@ -333,7 +337,7 @@ pub fn build_router( // which is the other way around compared to `tower::ServiceBuilder`. // So even if the Sentry docs has an example that does // 'NewSentryHttpLayer then SentryHttpLayer', we must do the opposite. - .layer(SentryHttpLayer::with_transaction()) + .layer(SentryHttpLayer::new().enable_transaction()) .layer(NewSentryLayer::new_from_top()) .with_state(state) } diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 0c1063607..e66b3aa50 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -1,17 +1,18 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Utilities to synchronize the configuration file with the database. use std::collections::{BTreeMap, BTreeSet}; use mas_config::{ClientsConfig, UpstreamOAuth2Config}; +use mas_data_model::Clock; use mas_keystore::Encrypter; use mas_storage::{ - Clock, Pagination, RepositoryAccess, + Pagination, RepositoryAccess, upstream_oauth2::{UpstreamOAuthProviderFilter, UpstreamOAuthProviderParams}, }; use mas_storage_pg::PgRepository; @@ -37,6 +38,19 @@ fn map_import_action( } } +fn map_import_on_conflict( + config: mas_config::UpstreamOAuth2OnConflict, +) -> mas_data_model::UpstreamOAuthProviderOnConflict { + match config { + mas_config::UpstreamOAuth2OnConflict::Add => { + mas_data_model::UpstreamOAuthProviderOnConflict::Add + } + mas_config::UpstreamOAuth2OnConflict::Fail => { + mas_data_model::UpstreamOAuthProviderOnConflict::Fail + } + } +} + fn map_claims_imports( config: &mas_config::UpstreamOAuth2ClaimsImports, ) -> mas_data_model::UpstreamOAuthProviderClaimsImports { @@ -44,9 +58,10 @@ fn map_claims_imports( subject: mas_data_model::UpstreamOAuthProviderSubjectPreference { template: config.subject.template.clone(), }, - localpart: mas_data_model::UpstreamOAuthProviderImportPreference { + localpart: mas_data_model::UpstreamOAuthProviderLocalpartPreference { action: map_import_action(config.localpart.action), template: config.localpart.template.clone(), + on_conflict: map_import_on_conflict(config.localpart.on_conflict), }, displayname: mas_data_model::UpstreamOAuthProviderImportPreference { action: map_import_action(config.displayname.action), @@ -117,7 +132,8 @@ pub async fn config_sync( let mut existing_enabled_ids = BTreeSet::new(); let mut existing_disabled = BTreeMap::new(); // Process the existing providers - for provider in page.edges { + for edge in page.edges { + let provider = edge.node; if provider.enabled() { if config_ids.contains(&provider.id) { existing_enabled_ids.insert(provider.id); @@ -194,11 +210,11 @@ pub async fn config_sync( // private key to hold the content of the private key file. // private key (raw) takes precedence so both can be defined // without issues - if siwa.private_key.is_none() { - if let Some(private_key_file) = siwa.private_key_file.take() { - let key = tokio::fs::read_to_string(private_key_file).await?; - siwa.private_key = Some(key); - } + if siwa.private_key.is_none() + && let Some(private_key_file) = siwa.private_key_file.take() + { + let key = tokio::fs::read_to_string(private_key_file).await?; + siwa.private_key = Some(key); } let encoded = serde_json::to_vec(&siwa)?; Some(encrypter.encrypt_to_string(&encoded)?) @@ -276,6 +292,18 @@ pub async fn config_sync( } }; + let on_backchannel_logout = match provider.on_backchannel_logout { + mas_config::UpstreamOAuth2OnBackchannelLogout::DoNothing => { + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing + } + mas_config::UpstreamOAuth2OnBackchannelLogout::LogoutBrowserOnly => { + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::LogoutBrowserOnly + } + mas_config::UpstreamOAuth2OnBackchannelLogout::LogoutAll => { + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::LogoutAll + } + }; + repo.upstream_oauth_provider() .upsert( clock, @@ -306,6 +334,7 @@ pub async fn config_sync( .collect(), forward_login_hint: provider.forward_login_hint, ui_order, + on_backchannel_logout, }, ) .await?; @@ -357,7 +386,7 @@ pub async fn config_sync( continue; } - let client_secret = client.client_secret.as_deref(); + let client_secret = client.client_secret().await?; let client_name = client.client_name.as_ref(); let client_auth_method = client.client_auth_method(); let jwks = client.jwks.as_ref(); diff --git a/crates/cli/src/telemetry.rs b/crates/cli/src/telemetry.rs index a09207e44..54222c8be 100644 --- a/crates/cli/src/telemetry.rs +++ b/crates/cli/src/telemetry.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod tokio; @@ -23,18 +23,17 @@ use opentelemetry::{ trace::TracerProvider as _, }; use opentelemetry_otlp::{WithExportConfig, WithHttpConfig}; -use opentelemetry_prometheus::PrometheusExporter; +use opentelemetry_prometheus_text_exporter::PrometheusExporter; use opentelemetry_sdk::{ Resource, metrics::{ManualReader, SdkMeterProvider, periodic_reader_with_async_runtime::PeriodicReader}, propagation::{BaggagePropagator, TraceContextPropagator}, trace::{ - Sampler, SdkTracerProvider, Tracer, span_processor_with_async_runtime::BatchSpanProcessor, + IdGenerator, Sampler, SdkTracerProvider, Tracer, + span_processor_with_async_runtime::BatchSpanProcessor, }, }; use opentelemetry_semantic_conventions as semcov; -use prometheus::Registry; -use url::Url; static SCOPE: LazyLock = LazyLock::new(|| { InstrumentationScope::builder(env!("CARGO_PKG_NAME")) @@ -49,7 +48,7 @@ pub static METER: LazyLock = pub static TRACER: OnceLock = OnceLock::new(); static METER_PROVIDER: OnceLock = OnceLock::new(); static TRACER_PROVIDER: OnceLock = OnceLock::new(); -static PROMETHEUS_REGISTRY: OnceLock = OnceLock::new(); +static PROMETHEUS_EXPORTER: OnceLock = OnceLock::new(); pub fn setup(config: &TelemetryConfig) -> anyhow::Result<()> { let propagator = propagator(&config.tracing.propagators); @@ -95,50 +94,65 @@ fn propagator(propagators: &[Propagator]) -> TextMapCompositePropagator { TextMapCompositePropagator::new(propagators) } -fn stdout_tracer_provider() -> SdkTracerProvider { - let exporter = opentelemetry_stdout::SpanExporter::default(); - SdkTracerProvider::builder() - .with_simple_exporter(exporter) - .build() +/// An [`IdGenerator`] which always returns an invalid trace ID and span ID +/// +/// This is used when no exporter is being used, so that we don't log the trace +/// ID when we're not tracing. +#[derive(Debug, Clone, Copy)] +struct InvalidIdGenerator; +impl IdGenerator for InvalidIdGenerator { + fn new_trace_id(&self) -> opentelemetry::TraceId { + opentelemetry::TraceId::INVALID + } + fn new_span_id(&self) -> opentelemetry::SpanId { + opentelemetry::SpanId::INVALID + } } -fn otlp_tracer_provider( - endpoint: Option<&Url>, - sample_rate: f64, -) -> anyhow::Result { - let mut exporter = opentelemetry_otlp::SpanExporter::builder() - .with_http() - .with_http_client(mas_http::reqwest_client()); - if let Some(endpoint) = endpoint { - exporter = exporter.with_endpoint(endpoint.to_string()); - } - let exporter = exporter - .build() - .context("Failed to configure OTLP trace exporter")?; - - let batch_processor = - BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build(); +fn init_tracer(config: &TracingConfig) -> anyhow::Result<()> { + let sample_rate = config.sample_rate.unwrap_or(1.0); // We sample traces based on the parent if we have one, and if not, we // sample a ratio based on the configured sample rate let sampler = Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(sample_rate))); - let tracer_provider = SdkTracerProvider::builder() - .with_span_processor(batch_processor) + let tracer_provider_builder = SdkTracerProvider::builder() .with_resource(resource()) - .with_sampler(sampler) - .build(); + .with_sampler(sampler); - Ok(tracer_provider) -} - -fn init_tracer(config: &TracingConfig) -> anyhow::Result<()> { - let sample_rate = config.sample_rate.unwrap_or(1.0); let tracer_provider = match config.exporter { - TracingExporterKind::None => return Ok(()), - TracingExporterKind::Stdout => stdout_tracer_provider(), - TracingExporterKind::Otlp => otlp_tracer_provider(config.endpoint.as_ref(), sample_rate)?, + TracingExporterKind::None => tracer_provider_builder + .with_id_generator(InvalidIdGenerator) + .with_sampler(Sampler::AlwaysOff) + .build(), + + TracingExporterKind::Stdout => { + let exporter = opentelemetry_stdout::SpanExporter::default(); + tracer_provider_builder + .with_simple_exporter(exporter) + .build() + } + + TracingExporterKind::Otlp => { + let mut exporter = opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_http_client(mas_http::reqwest_client()); + if let Some(endpoint) = &config.endpoint { + exporter = exporter.with_endpoint(endpoint.as_str()); + } + let exporter = exporter + .build() + .context("Failed to configure OTLP trace exporter")?; + + let batch_processor = + BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build(); + + tracer_provider_builder + .with_span_processor(batch_processor) + .build() + } }; + TRACER_PROVIDER .set(tracer_provider.clone()) .map_err(|_| anyhow::anyhow!("TRACER_PROVIDER was set twice"))?; @@ -180,21 +194,30 @@ type PromServiceFuture = #[allow(clippy::needless_pass_by_value)] fn prometheus_service_fn(_req: T) -> PromServiceFuture { - use prometheus::{Encoder, TextEncoder}; + let response = if let Some(exporter) = PROMETHEUS_EXPORTER.get() { + // We'll need some space for this, so we preallocate a bit + let mut buffer = Vec::with_capacity(1024); - let response = if let Some(registry) = PROMETHEUS_REGISTRY.get() { - let mut buffer = Vec::new(); - let encoder = TextEncoder::new(); - let metric_families = registry.gather(); + if let Err(err) = exporter.export(&mut buffer) { + tracing::error!( + error = &err as &dyn std::error::Error, + "Failed to export Prometheus metrics" + ); - // That shouldn't panic, unless we're constructing invalid labels - encoder.encode(&metric_families, &mut buffer).unwrap(); - - Response::builder() - .status(200) - .header(CONTENT_TYPE, encoder.format_type()) - .body(Full::new(Bytes::from(buffer))) - .unwrap() + Response::builder() + .status(500) + .header(CONTENT_TYPE, "text/plain") + .body(Full::new(Bytes::from_static( + b"Failed to export Prometheus metrics, see logs for details", + ))) + .unwrap() + } else { + Response::builder() + .status(200) + .header(CONTENT_TYPE, "text/plain;version=1.0.0") + .body(Full::new(Bytes::from(buffer))) + .unwrap() + } } else { Response::builder() .status(500) @@ -209,7 +232,7 @@ fn prometheus_service_fn(_req: T) -> PromServiceFuture { } pub fn prometheus_service() -> tower::util::ServiceFn PromServiceFuture> { - if PROMETHEUS_REGISTRY.get().is_none() { + if PROMETHEUS_EXPORTER.get().is_none() { tracing::warn!( "A Prometheus resource was mounted on a listener, but the Prometheus exporter was not setup in the config" ); @@ -219,16 +242,11 @@ pub fn prometheus_service() -> tower::util::ServiceFn PromServiceFut } fn prometheus_metric_reader() -> anyhow::Result { - let registry = Registry::new(); + let exporter = PrometheusExporter::builder().without_scope_info().build(); - PROMETHEUS_REGISTRY - .set(registry.clone()) - .map_err(|_| anyhow::anyhow!("PROMETHEUS_REGISTRY was set twice"))?; - - let exporter = opentelemetry_prometheus::exporter() - .with_registry(registry) - .without_scope_info() - .build()?; + PROMETHEUS_EXPORTER + .set(exporter.clone()) + .map_err(|_| anyhow::anyhow!("PROMETHEUS_EXPORTER was set twice"))?; Ok(exporter) } diff --git a/crates/cli/src/telemetry/tokio.rs b/crates/cli/src/telemetry/tokio.rs index 49c7ac0ef..7346a7620 100644 --- a/crates/cli/src/telemetry/tokio.rs +++ b/crates/cli/src/telemetry/tokio.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use opentelemetry::KeyValue; use tokio::runtime::RuntimeMetrics; @@ -9,7 +9,6 @@ use tokio::runtime::RuntimeMetrics; use super::METER; /// Install metrics for the tokio runtime. -#[allow(clippy::too_many_lines)] pub fn observe(metrics: RuntimeMetrics) { { let metrics = metrics.clone(); diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index b6725a0eb..4925d9866 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{sync::Arc, time::Duration}; @@ -17,7 +17,7 @@ use mas_data_model::{SessionExpirationConfig, SiteConfig}; use mas_email::{MailTransport, Mailer}; use mas_handlers::passwords::PasswordManager; use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection}; -use mas_matrix_synapse::SynapseConnection; +use mas_matrix_synapse::{LegacySynapseConnection, SynapseConnection}; use mas_policy::PolicyFactory; use mas_router::UrlBuilder; use mas_storage::{BoxRepositoryFactory, RepositoryAccess, RepositoryFactory}; @@ -211,6 +211,7 @@ pub fn site_config_from_config( password_login_enabled: password_config.enabled(), password_registration_enabled: password_config.enabled() && account_config.password_registration_enabled, + password_registration_email_required: account_config.password_registration_email_required, registration_token_required: account_config.registration_token_required, email_change_allowed: account_config.email_change_allowed, displayname_change_allowed: account_config.displayname_change_allowed, @@ -231,6 +232,7 @@ pub async fn templates_from_config( config: &TemplatesConfig, site_config: &SiteConfig, url_builder: &UrlBuilder, + strict: bool, ) -> Result { Templates::load( config.path.clone(), @@ -239,6 +241,7 @@ pub async fn templates_from_config( config.translations_path.clone(), site_config.templates_branding(), site_config.templates_features(), + strict, ) .await .with_context(|| format!("Failed to load the templates at {}", config.path)) @@ -464,28 +467,36 @@ pub async fn load_policy_factory_dynamic_data( /// Create a clonable, type-erased [`HomeserverConnection`] from the /// configuration -pub fn homeserver_connection_from_config( +pub async fn homeserver_connection_from_config( config: &MatrixConfig, http_client: reqwest::Client, -) -> Arc { - match config.kind { - HomeserverKind::Synapse => Arc::new(SynapseConnection::new( +) -> anyhow::Result> { + Ok(match config.kind { + HomeserverKind::Synapse | HomeserverKind::SynapseModern => { + Arc::new(SynapseConnection::new( + config.homeserver.clone(), + config.endpoint.clone(), + config.secret().await?, + http_client, + )) + } + HomeserverKind::SynapseLegacy => Arc::new(LegacySynapseConnection::new( config.homeserver.clone(), config.endpoint.clone(), - config.secret.clone(), + config.secret().await?, http_client, )), HomeserverKind::SynapseReadOnly => { let connection = SynapseConnection::new( config.homeserver.clone(), config.endpoint.clone(), - config.secret.clone(), + config.secret().await?, http_client, ); let readonly = ReadOnlyHomeserverConnection::new(connection); Arc::new(readonly) } - } + }) } #[cfg(test)] diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 35b7f83d1..c6eb76d7c 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-config" version.workspace = true diff --git a/crates/config/src/bin/schema.rs b/crates/config/src/bin/schema.rs index 401d52baa..1bcbe95a6 100644 --- a/crates/config/src/bin/schema.rs +++ b/crates/config/src/bin/schema.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use schemars::{ generate::SchemaSettings, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d8230a9fb..cdf68e420 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(missing_docs, rustdoc::missing_crate_level_docs)] #![allow(clippy::module_name_repetitions)] diff --git a/crates/config/src/schema.rs b/crates/config/src/schema.rs index 537c0ffb6..7c1761e30 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Useful JSON Schema definitions diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index a9d51afbb..2b6538a2b 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -50,6 +50,13 @@ pub struct AccountConfig { #[serde(default = "default_false", skip_serializing_if = "is_default_false")] pub password_registration_enabled: bool, + /// Whether self-service password registrations require a valid email. + /// Defaults to `true`. + /// + /// This has no effect if password registration is disabled. + #[serde(default = "default_true", skip_serializing_if = "is_default_true")] + pub password_registration_email_required: bool, + /// Whether users are allowed to change their passwords. Defaults to `true`. /// /// This has no effect if password login is disabled. @@ -89,6 +96,7 @@ impl Default for AccountConfig { email_change_allowed: default_true(), displayname_change_allowed: default_true(), password_registration_enabled: default_false(), + password_registration_email_required: default_true(), password_change_allowed: default_true(), password_recovery_enabled: default_false(), account_deactivation_allowed: default_true(), diff --git a/crates/config/src/sections/branding.rs b/crates/config/src/sections/branding.rs index 91bba2cf9..ec36f1612 100644 --- a/crates/config/src/sections/branding.rs +++ b/crates/config/src/sections/branding.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/config/src/sections/captcha.rs b/crates/config/src/sections/captcha.rs index 58b30799d..962d1f342 100644 --- a/crates/config/src/sections/captcha.rs +++ b/crates/config/src/sections/captcha.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::Error}; @@ -51,7 +51,10 @@ impl CaptchaConfig { impl ConfigurationSection for CaptchaConfig { const PATH: Option<&'static str> = Some("captcha"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { let metadata = figment.find_metadata(Self::PATH.unwrap()); let error_on_field = |mut error: figment::error::Error, field: &'static str| { @@ -67,11 +70,11 @@ impl ConfigurationSection for CaptchaConfig { if let Some(CaptchaServiceKind::RecaptchaV2) = self.service { if self.site_key.is_none() { - return Err(missing_field("site_key")); + return Err(missing_field("site_key").into()); } if self.secret_key.is_none() { - return Err(missing_field("secret_key")); + return Err(missing_field("secret_key").into()); } } diff --git a/crates/config/src/sections/clients.rs b/crates/config/src/sections/clients.rs index 2a0469677..0951ebba2 100644 --- a/crates/config/src/sections/clients.rs +++ b/crates/config/src/sections/clients.rs @@ -1,16 +1,18 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::ops::Deref; -use figment::Figment; +use anyhow::bail; +use camino::Utf8PathBuf; use mas_iana::oauth::OAuthClientAuthenticationMethod; use mas_jose::jwk::PublicJsonWebKeySet; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::Error}; +use serde_with::serde_as; use ulid::Ulid; use url::Url; @@ -29,6 +31,66 @@ impl From for JwksOrJwksUri { } } +/// Client secret config option. +/// +/// It either holds the client secret value directly or references a file where +/// the client secret is stored. +#[derive(Clone, Debug)] +pub enum ClientSecret { + File(Utf8PathBuf), + Value(String), +} + +/// Client secret fields as serialized in JSON. +#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] +struct ClientSecretRaw { + /// Path to the file containing the client secret. The client secret is used + /// by the `client_secret_basic`, `client_secret_post` and + /// `client_secret_jwt` authentication methods. + #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] + client_secret_file: Option, + + /// Alternative to `client_secret_file`: Reads the client secret directly + /// from the config. + #[serde(skip_serializing_if = "Option::is_none")] + client_secret: Option, +} + +impl TryFrom for Option { + type Error = anyhow::Error; + + fn try_from(value: ClientSecretRaw) -> Result { + match (value.client_secret, value.client_secret_file) { + (None, None) => Ok(None), + (None, Some(path)) => Ok(Some(ClientSecret::File(path))), + (Some(client_secret), None) => Ok(Some(ClientSecret::Value(client_secret))), + (Some(_), Some(_)) => { + bail!("Cannot specify both `client_secret` and `client_secret_file`") + } + } + } +} + +impl From> for ClientSecretRaw { + fn from(value: Option) -> Self { + match value { + Some(ClientSecret::File(path)) => ClientSecretRaw { + client_secret_file: Some(path), + client_secret: None, + }, + Some(ClientSecret::Value(client_secret)) => ClientSecretRaw { + client_secret_file: None, + client_secret: Some(client_secret), + }, + None => ClientSecretRaw { + client_secret_file: None, + client_secret: None, + }, + } + } +} + /// Authentication method used by clients #[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)] #[serde(rename_all = "snake_case")] @@ -66,6 +128,7 @@ impl std::fmt::Display for ClientAuthMethodConfig { } /// An OAuth 2.0 client configuration +#[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ClientConfig { /// The client ID @@ -85,8 +148,10 @@ pub struct ClientConfig { /// The client secret, used by the `client_secret_basic`, /// `client_secret_post` and `client_secret_jwt` authentication methods - #[serde(skip_serializing_if = "Option::is_none")] - pub client_secret: Option, + #[schemars(with = "ClientSecretRaw")] + #[serde_as(as = "serde_with::TryFromInto")] + #[serde(flatten)] + pub client_secret: Option, /// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication /// method. Mutually exclusive with `jwks_uri` @@ -104,7 +169,7 @@ pub struct ClientConfig { } impl ClientConfig { - fn validate(&self) -> Result<(), figment::error::Error> { + fn validate(&self) -> Result<(), Box> { let auth_method = self.client_auth_method; match self.client_auth_method { ClientAuthMethodConfig::PrivateKeyJwt => { @@ -112,20 +177,20 @@ impl ClientConfig { let error = figment::error::Error::custom( "jwks or jwks_uri is required for private_key_jwt", ); - return Err(error.with_path("client_auth_method")); + return Err(Box::new(error.with_path("client_auth_method"))); } if self.jwks.is_some() && self.jwks_uri.is_some() { let error = figment::error::Error::custom("jwks and jwks_uri are mutually exclusive"); - return Err(error.with_path("jwks")); + return Err(Box::new(error.with_path("jwks"))); } if self.client_secret.is_some() { let error = figment::error::Error::custom( "client_secret is not allowed with private_key_jwt", ); - return Err(error.with_path("client_secret")); + return Err(Box::new(error.with_path("client_secret"))); } } @@ -136,21 +201,21 @@ impl ClientConfig { let error = figment::error::Error::custom(format!( "client_secret is required for {auth_method}" )); - return Err(error.with_path("client_auth_method")); + return Err(Box::new(error.with_path("client_auth_method"))); } if self.jwks.is_some() { let error = figment::error::Error::custom(format!( "jwks is not allowed with {auth_method}" )); - return Err(error.with_path("jwks")); + return Err(Box::new(error.with_path("jwks"))); } if self.jwks_uri.is_some() { let error = figment::error::Error::custom(format!( "jwks_uri is not allowed with {auth_method}" )); - return Err(error.with_path("jwks_uri")); + return Err(Box::new(error.with_path("jwks_uri"))); } } @@ -159,21 +224,21 @@ impl ClientConfig { let error = figment::error::Error::custom( "client_secret is not allowed with none authentication method", ); - return Err(error.with_path("client_secret")); + return Err(Box::new(error.with_path("client_secret"))); } if self.jwks.is_some() { let error = figment::error::Error::custom( "jwks is not allowed with none authentication method", ); - return Err(error); + return Err(Box::new(error)); } if self.jwks_uri.is_some() { let error = figment::error::Error::custom( "jwks_uri is not allowed with none authentication method", ); - return Err(error); + return Err(Box::new(error)); } } } @@ -198,6 +263,21 @@ impl ClientConfig { ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt, } } + + /// Returns the client secret. + /// + /// If `client_secret_file` was given, the secret is read from that file. + /// + /// # Errors + /// + /// Returns an error when the client secret could not be read from file. + pub async fn client_secret(&self) -> anyhow::Result> { + Ok(match &self.client_secret { + Some(ClientSecret::File(path)) => Some(tokio::fs::read_to_string(path).await?), + Some(ClientSecret::Value(client_secret)) => Some(client_secret.clone()), + None => None, + }) + } } /// List of OAuth 2.0/OIDC clients config @@ -232,7 +312,10 @@ impl IntoIterator for ClientsConfig { impl ConfigurationSection for ClientsConfig { const PATH: Option<&'static str> = Some("clients"); - fn validate(&self, figment: &Figment) -> Result<(), figment::error::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { for (index, client) in self.0.iter().enumerate() { client.validate().map_err(|mut err| { // Save the error location information in the error @@ -256,75 +339,91 @@ mod tests { Figment, Jail, providers::{Format, Yaml}, }; + use tokio::{runtime::Handle, task}; use super::*; - #[test] - fn load_config() { - Jail::expect_with(|jail| { - jail.create_file( - "config.yaml", - r#" - clients: - - client_id: 01GFWR28C4KNE04WG3HKXB7C9R - client_auth_method: none - redirect_uris: - - https://exemple.fr/callback + #[tokio::test] + async fn load_config() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + r#" + clients: + - client_id: 01GFWR28C4KNE04WG3HKXB7C9R + client_auth_method: none + redirect_uris: + - https://exemple.fr/callback - - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6 - client_auth_method: client_secret_basic - client_secret: hello + - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6 + client_auth_method: client_secret_basic + client_secret_file: secret - - client_id: 01GFWR3WHR93Y5HK389H28VHZ9 - client_auth_method: client_secret_post - client_secret: hello + - client_id: 01GFWR3WHR93Y5HK389H28VHZ9 + client_auth_method: client_secret_post + client_secret: c1!3n753c237 - - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG - client_auth_method: client_secret_jwt - client_secret: hello + - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG + client_auth_method: client_secret_jwt + client_secret_file: secret - - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR - client_auth_method: private_key_jwt - jwks: - keys: - - kid: "03e84aed4ef4431014e8617567864c4efaaaede9" - kty: "RSA" - alg: "RS256" - use: "sig" - e: "AQAB" - n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw" + - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR + client_auth_method: private_key_jwt + jwks: + keys: + - kid: "03e84aed4ef4431014e8617567864c4efaaaede9" + kty: "RSA" + alg: "RS256" + use: "sig" + e: "AQAB" + n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw" - - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567" - kty: "RSA" - alg: "RS256" - use: "sig" - e: "AQAB" - n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw" - "#, - )?; + - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567" + kty: "RSA" + alg: "RS256" + use: "sig" + e: "AQAB" + n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw" + "#, + )?; + jail.create_file("secret", r"c1!3n753c237")?; - let config = Figment::new() - .merge(Yaml::file("config.yaml")) - .extract_inner::("clients")?; + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("clients")?; - assert_eq!(config.0.len(), 5); + assert_eq!(config.0.len(), 5); - assert_eq!( - config.0[0].client_id, - Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap() - ); - assert_eq!( - config.0[0].redirect_uris, - vec!["https://exemple.fr/callback".parse().unwrap()] - ); + assert_eq!( + config.0[0].client_id, + Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap() + ); + assert_eq!( + config.0[0].redirect_uris, + vec!["https://exemple.fr/callback".parse().unwrap()] + ); - assert_eq!( - config.0[1].client_id, - Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap() - ); - assert_eq!(config.0[1].redirect_uris, Vec::new()); + assert_eq!( + config.0[1].client_id, + Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap() + ); + assert_eq!(config.0[1].redirect_uris, Vec::new()); - Ok(()) - }); + assert!(config.0[0].client_secret.is_none()); + assert!(matches!(config.0[1].client_secret, Some(ClientSecret::File(ref p)) if p == "secret")); + assert!(matches!(config.0[2].client_secret, Some(ClientSecret::Value(ref v)) if v == "c1!3n753c237")); + assert!(matches!(config.0[3].client_secret, Some(ClientSecret::File(ref p)) if p == "secret")); + assert!(config.0[4].client_secret.is_none()); + + Handle::current().block_on(async move { + assert_eq!(config.0[1].client_secret().await.unwrap().unwrap(), "c1!3n753c237"); + assert_eq!(config.0[2].client_secret().await.unwrap().unwrap(), "c1!3n753c237"); + assert_eq!(config.0[3].client_secret().await.unwrap().unwrap(), "c1!3n753c237"); + }); + + Ok(()) + }); + }).await.unwrap(); } } diff --git a/crates/config/src/sections/database.rs b/crates/config/src/sections/database.rs index e2b701c60..4830a4016 100644 --- a/crates/config/src/sections/database.rs +++ b/crates/config/src/sections/database.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{num::NonZeroU32, time::Duration}; @@ -222,13 +222,16 @@ pub struct DatabaseConfig { impl ConfigurationSection for DatabaseConfig { const PATH: Option<&'static str> = Some("database"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { let metadata = figment.find_metadata(Self::PATH.unwrap()); let annotate = |mut error: figment::Error| { error.metadata = metadata.cloned(); error.profile = Some(figment::Profile::Default); error.path = vec![Self::PATH.unwrap().to_owned()]; - Err(error) + error }; // Check that the user did not specify both `uri` and the split options at the @@ -241,37 +244,41 @@ impl ConfigurationSection for DatabaseConfig { || self.database.is_some(); if self.uri.is_some() && has_split_options { - return annotate(figment::error::Error::from( + return Err(annotate(figment::error::Error::from( "uri must not be specified if host, port, socket, username, password, or database are specified".to_owned(), - )); + )).into()); } if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() { - return annotate(figment::error::Error::from( + return Err(annotate(figment::error::Error::from( "ssl_ca must not be specified if ssl_ca_file is specified".to_owned(), - )); + )) + .into()); } if self.ssl_certificate.is_some() && self.ssl_certificate_file.is_some() { - return annotate(figment::error::Error::from( + return Err(annotate(figment::error::Error::from( "ssl_certificate must not be specified if ssl_certificate_file is specified" .to_owned(), - )); + )) + .into()); } if self.ssl_key.is_some() && self.ssl_key_file.is_some() { - return annotate(figment::error::Error::from( + return Err(annotate(figment::error::Error::from( "ssl_key must not be specified if ssl_key_file is specified".to_owned(), - )); + )) + .into()); } if (self.ssl_key.is_some() || self.ssl_key_file.is_some()) ^ (self.ssl_certificate.is_some() || self.ssl_certificate_file.is_some()) { - return annotate(figment::error::Error::from( + return Err(annotate(figment::error::Error::from( "both a ssl_certificate and a ssl_key must be set at the same time or none of them" .to_owned(), - )); + )) + .into()); } Ok(()) diff --git a/crates/config/src/sections/email.rs b/crates/config/src/sections/email.rs index 18f86df13..3df0c99db 100644 --- a/crates/config/src/sections/email.rs +++ b/crates/config/src/sections/email.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(deprecated)] @@ -175,7 +175,10 @@ impl Default for EmailConfig { impl ConfigurationSection for EmailConfig { const PATH: Option<&'static str> = Some("email"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { let metadata = figment.find_metadata(Self::PATH.unwrap()); let error_on_field = |mut error: figment::error::Error, field: &'static str| { @@ -201,29 +204,29 @@ impl ConfigurationSection for EmailConfig { EmailTransportKind::Smtp => { if let Err(e) = Mailbox::from_str(&self.from) { - return Err(error_on_field(figment::error::Error::custom(e), "from")); + return Err(error_on_field(figment::error::Error::custom(e), "from").into()); } if let Err(e) = Mailbox::from_str(&self.reply_to) { - return Err(error_on_field(figment::error::Error::custom(e), "reply_to")); + return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into()); } match (self.username.is_some(), self.password.is_some()) { (true, true) | (false, false) => {} (true, false) => { - return Err(missing_field("password")); + return Err(missing_field("password").into()); } (false, true) => { - return Err(missing_field("username")); + return Err(missing_field("username").into()); } } if self.mode.is_none() { - return Err(missing_field("mode")); + return Err(missing_field("mode").into()); } if self.hostname.is_none() { - return Err(missing_field("hostname")); + return Err(missing_field("hostname").into()); } if self.command.is_some() { @@ -239,7 +242,8 @@ impl ConfigurationSection for EmailConfig { "username", "password", ], - )); + ) + .into()); } } @@ -247,35 +251,35 @@ impl ConfigurationSection for EmailConfig { let expected_fields = &["from", "reply_to", "transport", "command"]; if let Err(e) = Mailbox::from_str(&self.from) { - return Err(error_on_field(figment::error::Error::custom(e), "from")); + return Err(error_on_field(figment::error::Error::custom(e), "from").into()); } if let Err(e) = Mailbox::from_str(&self.reply_to) { - return Err(error_on_field(figment::error::Error::custom(e), "reply_to")); + return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into()); } if self.command.is_none() { - return Err(missing_field("command")); + return Err(missing_field("command").into()); } if self.mode.is_some() { - return Err(unexpected_field("mode", expected_fields)); + return Err(unexpected_field("mode", expected_fields).into()); } if self.hostname.is_some() { - return Err(unexpected_field("hostname", expected_fields)); + return Err(unexpected_field("hostname", expected_fields).into()); } if self.port.is_some() { - return Err(unexpected_field("port", expected_fields)); + return Err(unexpected_field("port", expected_fields).into()); } if self.username.is_some() { - return Err(unexpected_field("username", expected_fields)); + return Err(unexpected_field("username", expected_fields).into()); } if self.password.is_some() { - return Err(unexpected_field("password", expected_fields)); + return Err(unexpected_field("password", expected_fields).into()); } } } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 35e29e0de..c6c50e88d 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::Duration; use schemars::JsonSchema; diff --git a/crates/config/src/sections/http.rs b/crates/config/src/sections/http.rs index 1c11a8cbc..c01b8eb0d 100644 --- a/crates/config/src/sections/http.rs +++ b/crates/config/src/sections/http.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(deprecated)] @@ -400,7 +400,10 @@ impl Default for HttpConfig { impl ConfigurationSection for HttpConfig { const PATH: Option<&'static str> = Some("http"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { for (index, listener) in self.listeners.iter().enumerate() { let annotate = |mut error: figment::Error| { error.metadata = figment @@ -412,49 +415,57 @@ impl ConfigurationSection for HttpConfig { "listeners".to_owned(), index.to_string(), ]; - Err(error) + error }; if listener.resources.is_empty() { - return annotate(figment::Error::from("listener has no resources".to_owned())); + return Err( + annotate(figment::Error::from("listener has no resources".to_owned())).into(), + ); } if listener.binds.is_empty() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "listener does not bind to any address".to_owned(), - )); + )) + .into()); } if let Some(tls_config) = &listener.tls { if tls_config.certificate.is_some() && tls_config.certificate_file.is_some() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "Only one of `certificate` or `certificate_file` can be set at a time" .to_owned(), - )); + )) + .into()); } if tls_config.certificate.is_none() && tls_config.certificate_file.is_none() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "TLS configuration is missing a certificate".to_owned(), - )); + )) + .into()); } if tls_config.key.is_some() && tls_config.key_file.is_some() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "Only one of `key` or `key_file` can be set at a time".to_owned(), - )); + )) + .into()); } if tls_config.key.is_none() && tls_config.key_file.is_none() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "TLS configuration is missing a private key".to_owned(), - )); + )) + .into()); } if tls_config.password.is_some() && tls_config.password_file.is_some() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "Only one of `password` or `password_file` can be set at a time".to_owned(), - )); + )) + .into()); } } } diff --git a/crates/config/src/sections/matrix.rs b/crates/config/src/sections/matrix.rs index d5e35907e..1b08c1a07 100644 --- a/crates/config/src/sections/matrix.rs +++ b/crates/config/src/sections/matrix.rs @@ -1,9 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +use anyhow::bail; +use camino::Utf8PathBuf; use rand::{ Rng, distributions::{Alphanumeric, DistString}, @@ -27,15 +29,69 @@ fn default_endpoint() -> Url { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "snake_case")] pub enum HomeserverKind { - /// Homeserver is Synapse + /// Homeserver is Synapse, version 1.135.0 or newer #[default] Synapse, - /// Homeserver is Synapse, in read-only mode + /// Homeserver is Synapse, version 1.135.0 or newer, in read-only mode /// /// This is meant for testing rolling out Matrix Authentication Service with /// no risk of writing data to the homeserver. SynapseReadOnly, + + /// Homeserver is Synapse, using the legacy API + SynapseLegacy, + + /// Homeserver is Synapse, with the modern API available (>= 1.135.0) + SynapseModern, +} + +/// Shared secret between MAS and the homeserver. +/// +/// It either holds the secret value directly or references a file where the +/// secret is stored. +#[derive(Clone, Debug)] +pub enum Secret { + File(Utf8PathBuf), + Value(String), +} + +/// Secret fields as serialized in JSON. +#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] +struct SecretRaw { + #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] + secret_file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + secret: Option, +} + +impl TryFrom for Secret { + type Error = anyhow::Error; + + fn try_from(value: SecretRaw) -> Result { + match (value.secret, value.secret_file) { + (None, None) => bail!("Missing `secret` or `secret_file`"), + (None, Some(path)) => Ok(Secret::File(path)), + (Some(secret), None) => Ok(Secret::Value(secret)), + (Some(_), Some(_)) => bail!("Cannot specify both `secret` and `secret_file`"), + } + } +} + +impl From for SecretRaw { + fn from(value: Secret) -> Self { + match value { + Secret::File(path) => SecretRaw { + secret_file: Some(path), + secret: None, + }, + Secret::Value(secret) => SecretRaw { + secret_file: None, + secret: Some(secret), + }, + } + } } /// Configuration related to the Matrix homeserver @@ -51,7 +107,10 @@ pub struct MatrixConfig { pub homeserver: String, /// Shared secret to use for calls to the admin API - pub secret: String, + #[schemars(with = "SecretRaw")] + #[serde_as(as = "serde_with::TryFromInto")] + #[serde(flatten)] + pub secret: Secret, /// The base URL of the homeserver's client API #[serde(default = "default_endpoint")] @@ -63,6 +122,24 @@ impl ConfigurationSection for MatrixConfig { } impl MatrixConfig { + /// Returns the shared secret. + /// + /// If `secret_file` was given, the secret is read from that file. + /// + /// # Errors + /// + /// Returns an error when the shared secret could not be read from file. + pub async fn secret(&self) -> anyhow::Result { + Ok(match &self.secret { + Secret::File(path) => { + let raw = tokio::fs::read_to_string(path).await?; + // Trim the secret when read from file to match Synapse's behaviour + raw.trim().to_string() + } + Secret::Value(secret) => secret.clone(), + }) + } + pub(crate) fn generate(mut rng: R) -> Self where R: Rng + Send, @@ -70,7 +147,7 @@ impl MatrixConfig { Self { kind: HomeserverKind::default(), homeserver: default_homeserver(), - secret: Alphanumeric.sample_string(&mut rng, 32), + secret: Secret::Value(Alphanumeric.sample_string(&mut rng, 32)), endpoint: default_endpoint(), } } @@ -79,7 +156,7 @@ impl MatrixConfig { Self { kind: HomeserverKind::default(), homeserver: default_homeserver(), - secret: "test".to_owned(), + secret: Secret::Value("test".to_owned()), endpoint: default_endpoint(), } } @@ -91,29 +168,68 @@ mod tests { Figment, Jail, providers::{Format, Yaml}, }; + use tokio::{runtime::Handle, task}; use super::*; - #[test] - fn load_config() { - Jail::expect_with(|jail| { - jail.create_file( - "config.yaml", - r" - matrix: - homeserver: matrix.org - secret: test - ", - )?; + #[tokio::test] + async fn load_config() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + r" + matrix: + homeserver: matrix.org + secret_file: secret + ", + )?; + jail.create_file("secret", r"m472!x53c237")?; - let config = Figment::new() - .merge(Yaml::file("config.yaml")) - .extract_inner::("matrix")?; + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("matrix")?; - assert_eq!(&config.homeserver, "matrix.org"); - assert_eq!(&config.secret, "test"); + Handle::current().block_on(async move { + assert_eq!(&config.homeserver, "matrix.org"); + assert!(matches!(config.secret, Secret::File(ref p) if p == "secret")); + assert_eq!(config.secret().await.unwrap(), "m472!x53c237"); + }); - Ok(()) - }); + Ok(()) + }); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn load_config_inline_secrets() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + r" + matrix: + homeserver: matrix.org + secret: m472!x53c237 + ", + )?; + + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("matrix")?; + + Handle::current().block_on(async move { + assert_eq!(&config.homeserver, "matrix.org"); + assert!(matches!(config.secret, Secret::Value(ref v) if v == "m472!x53c237")); + assert_eq!(config.secret().await.unwrap(), "m472!x53c237"); + }); + + Ok(()) + }); + }) + .await + .unwrap(); } } diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 9a9fc9de8..f992d8698 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use rand::Rng; use schemars::JsonSchema; @@ -52,7 +52,9 @@ pub use self::{ upstream_oauth2::{ ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode, EmailImportPreference as UpstreamOAuth2EmailImportPreference, - ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod, + ImportAction as UpstreamOAuth2ImportAction, + OnBackchannelLogout as UpstreamOAuth2OnBackchannelLogout, + OnConflict as UpstreamOAuth2OnConflict, PkceMethod as UpstreamOAuth2PkceMethod, Provider as UpstreamOAuth2Provider, ResponseMode as UpstreamOAuth2ResponseMode, TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config, }, @@ -128,7 +130,10 @@ pub struct RootConfig { } impl ConfigurationSection for RootConfig { - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { self.clients.validate(figment)?; self.http.validate(figment)?; self.database.validate(figment)?; @@ -247,7 +252,10 @@ pub struct AppConfig { } impl ConfigurationSection for AppConfig { - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { self.http.validate(figment)?; self.database.validate(figment)?; self.templates.validate(figment)?; @@ -283,7 +291,10 @@ pub struct SyncConfig { } impl ConfigurationSection for SyncConfig { - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { self.database.validate(figment)?; self.secrets.validate(figment)?; self.clients.validate(figment)?; diff --git a/crates/config/src/sections/passwords.rs b/crates/config/src/sections/passwords.rs index a72f2efd7..b15c009fc 100644 --- a/crates/config/src/sections/passwords.rs +++ b/crates/config/src/sections/passwords.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::cmp::Reverse; @@ -72,12 +72,15 @@ impl Default for PasswordsConfig { impl ConfigurationSection for PasswordsConfig { const PATH: Option<&'static str> = Some("passwords"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { let annotate = |mut error: figment::Error| { error.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned(); error.profile = Some(figment::Profile::Default); error.path = vec![Self::PATH.unwrap().to_owned()]; - Err(error) + error }; if !self.enabled { @@ -86,16 +89,18 @@ impl ConfigurationSection for PasswordsConfig { } if self.schemes.is_empty() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "Requires at least one password scheme in the config".to_owned(), - )); + )) + .into()); } for scheme in &self.schemes { if scheme.secret.is_some() && scheme.secret_file.is_some() { - return annotate(figment::Error::from( + return Err(annotate(figment::Error::from( "Cannot specify both `secret` and `secret_file`".to_owned(), - )); + )) + .into()); } } diff --git a/crates/config/src/sections/policy.rs b/crates/config/src/sections/policy.rs index 03ec09e44..37d052ade 100644 --- a/crates/config/src/sections/policy.rs +++ b/crates/config/src/sections/policy.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use camino::Utf8PathBuf; use schemars::JsonSchema; diff --git a/crates/config/src/sections/rate_limiting.rs b/crates/config/src/sections/rate_limiting.rs index d95a2ab28..0b7c95dbc 100644 --- a/crates/config/src/sections/rate_limiting.rs +++ b/crates/config/src/sections/rate_limiting.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{num::NonZeroU32, time::Duration}; @@ -117,7 +117,10 @@ pub struct RateLimiterConfiguration { impl ConfigurationSection for RateLimitingConfig { const PATH: Option<&'static str> = Some("rate_limiting"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { let metadata = figment.find_metadata(Self::PATH.unwrap()); let error_on_field = |mut error: figment::error::Error, field: &'static str| { @@ -154,25 +157,21 @@ impl ConfigurationSection for RateLimitingConfig { }; if let Some(error) = error_on_limiter(&self.account_recovery.per_ip) { - return Err(error_on_nested_field(error, "account_recovery", "per_ip")); + return Err(error_on_nested_field(error, "account_recovery", "per_ip").into()); } if let Some(error) = error_on_limiter(&self.account_recovery.per_address) { - return Err(error_on_nested_field( - error, - "account_recovery", - "per_address", - )); + return Err(error_on_nested_field(error, "account_recovery", "per_address").into()); } if let Some(error) = error_on_limiter(&self.registration) { - return Err(error_on_field(error, "registration")); + return Err(error_on_field(error, "registration").into()); } if let Some(error) = error_on_limiter(&self.login.per_ip) { - return Err(error_on_nested_field(error, "login", "per_ip")); + return Err(error_on_nested_field(error, "login", "per_ip").into()); } if let Some(error) = error_on_limiter(&self.login.per_account) { - return Err(error_on_nested_field(error, "login", "per_account")); + return Err(error_on_nested_field(error, "login", "per_account").into()); } Ok(()) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index a28be9121..003f932ec 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -1,21 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::borrow::Cow; use anyhow::{Context, bail}; use camino::Utf8PathBuf; use futures_util::future::{try_join, try_join_all}; -use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; +use mas_jose::jwk::{JsonWebKey, JsonWebKeySet, Thumbprint}; use mas_keystore::{Encrypter, Keystore, PrivateKey}; -use rand::{ - Rng, SeedableRng, - distributions::{Alphanumeric, DistString, Standard}, - prelude::Distribution as _, -}; +use rand::{Rng, SeedableRng, distributions::Standard, prelude::Distribution as _}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_with::serde_as; @@ -128,7 +124,11 @@ impl From for KeyRaw { #[serde_as] #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] pub struct KeyConfig { - kid: String, + /// The key ID `kid` of the key as used by JWKs. + /// + /// If not given, `kid` will be the key’s RFC 7638 JWK Thumbprint. + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option, #[schemars(with = "PasswordRaw")] #[serde_as(as = "serde_with::TryFromInto")] @@ -145,10 +145,10 @@ impl KeyConfig { /// Returns the password in case any is provided. /// /// If `password_file` was given, the password is read from that file. - async fn password(&self) -> anyhow::Result>> { + async fn password(&self) -> anyhow::Result>> { Ok(match &self.password { - Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)), - Some(Password::Value(password)) => Some(Cow::Borrowed(password)), + Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read(path).await?)), + Some(Password::Value(password)) => Some(Cow::Borrowed(password.as_bytes())), None => None, }) } @@ -156,10 +156,10 @@ impl KeyConfig { /// Returns the key. /// /// If `key_file` was given, the key is read from that file. - async fn key(&self) -> anyhow::Result> { + async fn key(&self) -> anyhow::Result> { Ok(match &self.key { - Key::File(path) => Cow::Owned(tokio::fs::read_to_string(path).await?), - Key::Value(key) => Cow::Borrowed(key), + Key::File(path) => Cow::Owned(tokio::fs::read(path).await?), + Key::Value(key) => Cow::Borrowed(key.as_bytes()), }) } @@ -170,12 +170,17 @@ impl KeyConfig { let (key, password) = try_join(self.key(), self.password()).await?; let private_key = match password { - Some(password) => PrivateKey::load_encrypted(key.as_bytes(), password.as_bytes())?, - None => PrivateKey::load(key.as_bytes())?, + Some(password) => PrivateKey::load_encrypted(&key, password)?, + None => PrivateKey::load(&key)?, + }; + + let kid = match self.kid.clone() { + Some(kid) => kid, + None => private_key.thumbprint_sha256_base64(), }; Ok(JsonWebKey::new(private_key) - .with_kid(self.kid.clone()) + .with_kid(kid) .with_use(mas_iana::jose::JsonWebKeyUse::Sig)) } } @@ -299,6 +304,7 @@ impl ConfigurationSection for SecretsConfig { } impl SecretsConfig { + #[expect(clippy::similar_names, reason = "Key type names are very similar")] #[tracing::instrument(skip_all)] pub(crate) async fn generate(mut rng: R) -> anyhow::Result where @@ -317,7 +323,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let rsa_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: None, password: None, key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -333,7 +339,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_p256_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: None, password: None, key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -343,13 +349,13 @@ impl SecretsConfig { let ec_p384_key = task::spawn_blocking(move || { let _entered = span.enter(); let ret = PrivateKey::generate_ec_p384(key_rng); - info!("Done generating EC P-256 key"); + info!("Done generating EC P-384 key"); ret }) .await .context("could not join blocking task")?; let ec_p384_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: None, password: None, key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -365,7 +371,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_k256_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: None, password: None, key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -378,7 +384,7 @@ impl SecretsConfig { pub(crate) fn test() -> Self { let rsa_key = KeyConfig { - kid: "abcdef".to_owned(), + kid: None, password: None, key: Key::Value( indoc::indoc! {r" @@ -397,7 +403,7 @@ impl SecretsConfig { ), }; let ecdsa_key = KeyConfig { - kid: "ghijkl".to_owned(), + kid: None, password: None, key: Key::Value( indoc::indoc! {r" @@ -417,3 +423,68 @@ impl SecretsConfig { } } } + +#[cfg(test)] +mod tests { + use figment::{ + Figment, Jail, + providers::{Format, Yaml}, + }; + use mas_jose::constraints::Constrainable; + use tokio::{runtime::Handle, task}; + + use super::*; + + #[tokio::test] + async fn load_config_inline_secrets() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + indoc::indoc! {r" + secrets: + encryption: >- + 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff + keys: + - kid: lekid0 + key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49 + AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl + fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw== + -----END EC PRIVATE KEY----- + - key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49 + AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9 + h27LAir5RqxByHvua2XsP46rSTChof78uw== + -----END EC PRIVATE KEY----- + "}, + )?; + + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("secrets")?; + + Handle::current().block_on(async move { + assert_eq!( + config.encryption().await.unwrap(), + [ + 0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136, + 136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255, + 255 + ] + ); + + let key_store = config.key_store().await.unwrap(); + assert!(key_store.iter().any(|k| k.kid() == Some("lekid0"))); + assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o"))); + }); + + Ok(()) + }); + }) + .await + .unwrap(); + } +} diff --git a/crates/config/src/sections/telemetry.rs b/crates/config/src/sections/telemetry.rs index 5b71b330e..9d9308fd9 100644 --- a/crates/config/src/sections/telemetry.rs +++ b/crates/config/src/sections/telemetry.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::Error as _}; @@ -182,32 +182,38 @@ impl TelemetryConfig { impl ConfigurationSection for TelemetryConfig { const PATH: Option<&'static str> = Some("telemetry"); - fn validate(&self, _figment: &figment::Figment) -> Result<(), figment::Error> { - if let Some(sample_rate) = self.sentry.sample_rate { - if !(0.0..=1.0).contains(&sample_rate) { - return Err(figment::error::Error::custom( - "Sentry sample rate must be between 0.0 and 1.0", - ) - .with_path("sentry.sample_rate")); - } + fn validate( + &self, + _figment: &figment::Figment, + ) -> Result<(), Box> { + if let Some(sample_rate) = self.sentry.sample_rate + && !(0.0..=1.0).contains(&sample_rate) + { + return Err(figment::error::Error::custom( + "Sentry sample rate must be between 0.0 and 1.0", + ) + .with_path("sentry.sample_rate") + .into()); } - if let Some(sample_rate) = self.sentry.traces_sample_rate { - if !(0.0..=1.0).contains(&sample_rate) { - return Err(figment::error::Error::custom( - "Sentry sample rate must be between 0.0 and 1.0", - ) - .with_path("sentry.traces_sample_rate")); - } + if let Some(sample_rate) = self.sentry.traces_sample_rate + && !(0.0..=1.0).contains(&sample_rate) + { + return Err(figment::error::Error::custom( + "Sentry sample rate must be between 0.0 and 1.0", + ) + .with_path("sentry.traces_sample_rate") + .into()); } - if let Some(sample_rate) = self.tracing.sample_rate { - if !(0.0..=1.0).contains(&sample_rate) { - return Err(figment::error::Error::custom( - "Tracing sample rate must be between 0.0 and 1.0", - ) - .with_path("tracing.sample_rate")); - } + if let Some(sample_rate) = self.tracing.sample_rate + && !(0.0..=1.0).contains(&sample_rate) + { + return Err(figment::error::Error::custom( + "Tracing sample rate must be between 0.0 and 1.0", + ) + .with_path("tracing.sample_rate") + .into()); } Ok(()) diff --git a/crates/config/src/sections/templates.rs b/crates/config/src/sections/templates.rs index dca53f0e7..5656de44b 100644 --- a/crates/config/src/sections/templates.rs +++ b/crates/config/src/sections/templates.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use camino::Utf8PathBuf; use schemars::JsonSchema; diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index aa6a27254..05f70cc67 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::BTreeMap; @@ -33,7 +33,10 @@ impl UpstreamOAuth2Config { impl ConfigurationSection for UpstreamOAuth2Config { const PATH: Option<&'static str> = Some("upstream_oauth2"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { for (index, provider) in self.providers.iter().enumerate() { let annotate = |mut error: figment::Error| { error.metadata = figment @@ -45,15 +48,16 @@ impl ConfigurationSection for UpstreamOAuth2Config { "providers".to_owned(), index.to_string(), ]; - Err(error) + error }; if !matches!(provider.discovery_mode, DiscoveryMode::Disabled) && provider.issuer.is_none() { - return annotate(figment::Error::custom( + return Err(annotate(figment::Error::custom( "The `issuer` field is required when discovery is enabled", - )); + )) + .into()); } match provider.token_endpoint_auth_method { @@ -61,16 +65,16 @@ impl ConfigurationSection for UpstreamOAuth2Config { | TokenAuthMethod::PrivateKeyJwt | TokenAuthMethod::SignInWithApple => { if provider.client_secret.is_some() { - return annotate(figment::Error::custom( + return Err(annotate(figment::Error::custom( "Unexpected field `client_secret` for the selected authentication method", - )); + )).into()); } } TokenAuthMethod::ClientSecretBasic | TokenAuthMethod::ClientSecretPost | TokenAuthMethod::ClientSecretJwt => { if provider.client_secret.is_none() { - return annotate(figment::Error::missing_field("client_secret")); + return Err(annotate(figment::Error::missing_field("client_secret")).into()); } } } @@ -81,16 +85,17 @@ impl ConfigurationSection for UpstreamOAuth2Config { | TokenAuthMethod::ClientSecretPost | TokenAuthMethod::SignInWithApple => { if provider.token_endpoint_auth_signing_alg.is_some() { - return annotate(figment::Error::custom( + return Err(annotate(figment::Error::custom( "Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method", - )); + )).into()); } } TokenAuthMethod::ClientSecretJwt | TokenAuthMethod::PrivateKeyJwt => { if provider.token_endpoint_auth_signing_alg.is_none() { - return annotate(figment::Error::missing_field( + return Err(annotate(figment::Error::missing_field( "token_endpoint_auth_signing_alg", - )); + )) + .into()); } } } @@ -98,18 +103,32 @@ impl ConfigurationSection for UpstreamOAuth2Config { match provider.token_endpoint_auth_method { TokenAuthMethod::SignInWithApple => { if provider.sign_in_with_apple.is_none() { - return annotate(figment::Error::missing_field("sign_in_with_apple")); + return Err( + annotate(figment::Error::missing_field("sign_in_with_apple")).into(), + ); } } _ => { if provider.sign_in_with_apple.is_some() { - return annotate(figment::Error::custom( + return Err(annotate(figment::Error::custom( "Unexpected field `sign_in_with_apple` for the selected authentication method", - )); + )).into()); } } } + + if matches!( + provider.claims_imports.localpart.on_conflict, + OnConflict::Add + ) && !matches!( + provider.claims_imports.localpart.action, + ImportAction::Force | ImportAction::Require + ) { + return Err(annotate(figment::Error::custom( + "The field `action` must be either `force` or `require` when `on_conflict` is set to `add`", + )).into()); + } } Ok(()) @@ -183,6 +202,26 @@ impl ImportAction { } } +/// How to handle an existing localpart claim +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OnConflict { + /// Fails the sso login on conflict + #[default] + Fail, + + /// Adds the oauth identity link, regardless of whether there is an existing + /// link or not + Add, +} + +impl OnConflict { + #[allow(clippy::trivially_copy_pass_by_ref)] + const fn is_default(&self) -> bool { + matches!(self, OnConflict::Fail) + } +} + /// What should be done for the subject attribute #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] pub struct SubjectImportPreference { @@ -211,6 +250,10 @@ pub struct LocalpartImportPreference { /// If not provided, the default template is `{{ user.preferred_username }}` #[serde(default, skip_serializing_if = "Option::is_none")] pub template: Option, + + /// How to handle conflicts on the claim, default value is `Fail` + #[serde(default, skip_serializing_if = "OnConflict::is_default")] + pub on_conflict: OnConflict, } impl LocalpartImportPreference { @@ -408,6 +451,29 @@ fn is_default_scope(scope: &str) -> bool { scope == default_scope() } +/// What to do when receiving an OIDC Backchannel logout request. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum OnBackchannelLogout { + /// Do nothing + #[default] + DoNothing, + + /// Only log out the MAS 'browser session' started by this OIDC session + LogoutBrowserOnly, + + /// Log out all sessions started by this OIDC session, including MAS + /// 'browser sessions' and client sessions + LogoutAll, +} + +impl OnBackchannelLogout { + #[allow(clippy::trivially_copy_pass_by_ref)] + const fn is_default(&self) -> bool { + matches!(self, OnBackchannelLogout::DoNothing) + } +} + /// Configuration for one upstream OAuth 2 provider. #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -583,4 +649,10 @@ pub struct Provider { /// Defaults to `false`. #[serde(default)] pub forward_login_hint: bool, + + /// What to do when receiving an OIDC Backchannel logout request. + /// + /// Defaults to `do_nothing`. + #[serde(default, skip_serializing_if = "OnBackchannelLogout::is_default")] + pub on_backchannel_logout: OnBackchannelLogout, } diff --git a/crates/config/src/util.rs b/crates/config/src/util.rs index c2ffd037b..d6cf58c3f 100644 --- a/crates/config/src/util.rs +++ b/crates/config/src/util.rs @@ -1,10 +1,10 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -use figment::{Figment, error::Error as FigmentError}; +use figment::Figment; use serde::de::DeserializeOwned; /// Trait implemented by all configuration section to help loading specific part @@ -18,7 +18,10 @@ pub trait ConfigurationSection: Sized + DeserializeOwned { /// # Errors /// /// Returns an error if the configuration is invalid - fn validate(&self, _figment: &Figment) -> Result<(), FigmentError> { + fn validate( + &self, + _figment: &Figment, + ) -> Result<(), Box> { Ok(()) } @@ -27,7 +30,9 @@ pub trait ConfigurationSection: Sized + DeserializeOwned { /// # Errors /// /// Returns an error if the configuration could not be loaded - fn extract(figment: &Figment) -> Result { + fn extract( + figment: &Figment, + ) -> Result> { let this: Self = if let Some(path) = Self::PATH { figment.extract_inner(path)? } else { @@ -49,7 +54,9 @@ pub trait ConfigurationSectionExt: ConfigurationSection + Default { /// # Errors /// /// Returns an error if the configuration section is invalid. - fn extract_or_default(figment: &Figment) -> Result { + fn extract_or_default( + figment: &Figment, + ) -> Result> { let this: Self = if let Some(path) = Self::PATH { // If the configuration section is not present, we return the default value if !figment.contains(path) { diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml index 46adc9911..ad86d1426 100644 --- a/crates/context/Cargo.toml +++ b/crates/context/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-context" version.workspace = true diff --git a/crates/context/src/fmt.rs b/crates/context/src/fmt.rs index b074a9c34..f4c4981e1 100644 --- a/crates/context/src/fmt.rs +++ b/crates/context/src/fmt.rs @@ -1,13 +1,10 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use console::{Color, Style}; -use opentelemetry::{ - TraceId, - trace::{SamplingDecision, TraceContextExt}, -}; +use opentelemetry::TraceId; use tracing::{Level, Subscriber}; use tracing_opentelemetry::OtelData; use tracing_subscriber::{ @@ -129,33 +126,16 @@ where field_fromatter.format_fields(writer.by_ref(), event)?; // If we have a OTEL span, we can add the trace ID to the end of the log line - if let Some(span) = ctx.lookup_current() { - if let Some(otel) = span.extensions().get::() { - let parent_cx_span = otel.parent_cx.span(); - let sc = parent_cx_span.span_context(); - - // Check if the span is sampled, first from the span builder, - // then from the parent context if nothing is set there - if otel - .builder - .sampling_result - .as_ref() - .map_or(sc.is_sampled(), |r| { - r.decision == SamplingDecision::RecordAndSample - }) - { - // If it is the root span, the trace ID will be in the span builder. Else, it - // will be in the parent OTEL context - let trace_id = otel.builder.trace_id.unwrap_or(sc.trace_id()); - if trace_id != TraceId::INVALID { - let label = Style::new() - .italic() - .force_styling(ansi) - .apply_to("trace.id"); - write!(&mut writer, " {label}={trace_id}")?; - } - } - } + if let Some(span) = ctx.lookup_current() + && let Some(otel) = span.extensions().get::() + && let Some(trace_id) = otel.trace_id() + && trace_id != TraceId::INVALID + { + let label = Style::new() + .italic() + .force_styling(ansi) + .apply_to("trace.id"); + write!(&mut writer, " {label}={trace_id}")?; } writeln!(&mut writer) diff --git a/crates/context/src/future.rs b/crates/context/src/future.rs index 9e93af4fa..67c77fe67 100644 --- a/crates/context/src/future.rs +++ b/crates/context/src/future.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ pin::Pin, diff --git a/crates/context/src/layer.rs b/crates/context/src/layer.rs index 0ce6e3497..eb3f92bf6 100644 --- a/crates/context/src/layer.rs +++ b/crates/context/src/layer.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::borrow::Cow; diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs index 655d407e9..c9644282d 100644 --- a/crates/context/src/lib.rs +++ b/crates/context/src/lib.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod fmt; mod future; diff --git a/crates/context/src/service.rs b/crates/context/src/service.rs index 98a1d1184..8d875cc0c 100644 --- a/crates/context/src/service.rs +++ b/crates/context/src/service.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ borrow::Cow, diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml index c4e19fcdf..da7021b11 100644 --- a/crates/data-model/Cargo.toml +++ b/crates/data-model/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-data-model" version.workspace = true @@ -21,10 +26,13 @@ url.workspace = true crc.workspace = true ulid.workspace = true rand.workspace = true +rand_chacha.workspace = true regex.workspace = true woothee.workspace = true ruma-common.workspace = true +lettre.workspace = true mas-iana.workspace = true mas-jose.workspace = true oauth2-types.workspace = true + diff --git a/crates/data-model/examples/ua-parser.rs b/crates/data-model/examples/ua-parser.rs index af2ec9179..98a4d7893 100644 --- a/crates/data-model/examples/ua-parser.rs +++ b/crates/data-model/examples/ua-parser.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_data_model::UserAgent; diff --git a/crates/storage/src/clock.rs b/crates/data-model/src/clock.rs similarity index 93% rename from crates/storage/src/clock.rs rename to crates/data-model/src/clock.rs index 6af9926e1..bf31835f0 100644 --- a/crates/storage/src/clock.rs +++ b/crates/data-model/src/clock.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A [`Clock`] is a way to get the current date and time. //! @@ -15,7 +15,7 @@ use std::sync::{Arc, atomic::AtomicI64}; use chrono::{DateTime, TimeZone, Utc}; /// Represents a clock which can give the current date and time -pub trait Clock: Sync { +pub trait Clock: Send + Sync { /// Get the current date and time fn now(&self) -> DateTime; } diff --git a/crates/data-model/src/compat/device.rs b/crates/data-model/src/compat/device.rs index ca34ff2ac..e275b740f 100644 --- a/crates/data-model/src/compat/device.rs +++ b/crates/data-model/src/compat/device.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use oauth2_types::scope::ScopeToken; use rand::{ @@ -13,7 +13,8 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; static GENERATED_DEVICE_ID_LENGTH: usize = 10; -static DEVICE_SCOPE_PREFIX: &str = "urn:matrix:org.matrix.msc2967.client:device:"; +static UNSTABLE_DEVICE_SCOPE_PREFIX: &str = "urn:matrix:org.matrix.msc2967.client:device:"; +static STABLE_DEVICE_SCOPE_PREFIX: &str = "urn:matrix:client:device:"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] @@ -28,16 +29,21 @@ pub enum ToScopeTokenError { } impl Device { - /// Get the corresponding [`ScopeToken`] for that device + /// Get the corresponding stable and unstable [`ScopeToken`] for that device /// /// # Errors /// /// Returns an error if the device ID contains characters that can't be /// encoded in a scope - pub fn to_scope_token(&self) -> Result { - format!("{DEVICE_SCOPE_PREFIX}{}", self.id) - .parse() - .map_err(|_| ToScopeTokenError::InvalidCharacters) + pub fn to_scope_token(&self) -> Result<[ScopeToken; 2], ToScopeTokenError> { + Ok([ + format!("{STABLE_DEVICE_SCOPE_PREFIX}{}", self.id) + .parse() + .map_err(|_| ToScopeTokenError::InvalidCharacters)?, + format!("{UNSTABLE_DEVICE_SCOPE_PREFIX}{}", self.id) + .parse() + .map_err(|_| ToScopeTokenError::InvalidCharacters)?, + ]) } /// Get the corresponding [`Device`] from a [`ScopeToken`] @@ -45,7 +51,9 @@ impl Device { /// Returns `None` if the [`ScopeToken`] is not a device scope #[must_use] pub fn from_scope_token(token: &ScopeToken) -> Option { - let id = token.as_str().strip_prefix(DEVICE_SCOPE_PREFIX)?; + let stable = token.as_str().strip_prefix(STABLE_DEVICE_SCOPE_PREFIX); + let unstable = token.as_str().strip_prefix(UNSTABLE_DEVICE_SCOPE_PREFIX); + let id = stable.or(unstable)?; Some(Device::from(id.to_owned())) } @@ -89,12 +97,23 @@ mod test { #[test] fn test_device_id_to_from_scope_token() { let device = Device::from("AABBCCDDEE".to_owned()); - let scope_token = device.to_scope_token().unwrap(); + let [stable_scope_token, unstable_scope_token] = device.to_scope_token().unwrap(); assert_eq!( - scope_token.as_str(), + stable_scope_token.as_str(), + "urn:matrix:client:device:AABBCCDDEE" + ); + assert_eq!( + unstable_scope_token.as_str(), "urn:matrix:org.matrix.msc2967.client:device:AABBCCDDEE" ); - assert_eq!(Device::from_scope_token(&scope_token), Some(device)); + assert_eq!( + Device::from_scope_token(&unstable_scope_token).as_ref(), + Some(&device) + ); + assert_eq!( + Device::from_scope_token(&stable_scope_token).as_ref(), + Some(&device) + ); assert_eq!(Device::from_scope_token(&OPENID), None); } } diff --git a/crates/data-model/src/compat/mod.rs b/crates/data-model/src/compat/mod.rs index c50d74261..be38154f3 100644 --- a/crates/data-model/src/compat/mod.rs +++ b/crates/data-model/src/compat/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use ulid::Ulid; diff --git a/crates/data-model/src/compat/session.rs b/crates/data-model/src/compat/session.rs index 91b48cea0..24a8e18a7 100644 --- a/crates/data-model/src/compat/session.rs +++ b/crates/data-model/src/compat/session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; diff --git a/crates/data-model/src/compat/sso_login.rs b/crates/data-model/src/compat/sso_login.rs index 448601a92..a42dfcb1d 100644 --- a/crates/data-model/src/compat/sso_login.rs +++ b/crates/data-model/src/compat/sso_login.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use serde::Serialize; diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index af4d0be37..962c8be00 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -1,21 +1,25 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::module_name_repetitions)] use thiserror::Error; +pub mod clock; pub(crate) mod compat; pub mod oauth2; +pub mod personal; pub(crate) mod policy_data; mod site_config; pub(crate) mod tokens; pub(crate) mod upstream_oauth2; pub(crate) mod user_agent; pub(crate) mod users; +mod utils; +mod version; /// Error when an invalid state transition is attempted. #[derive(Debug, Error)] @@ -25,6 +29,7 @@ pub struct InvalidTransitionError; pub use ulid::Ulid; pub use self::{ + clock::{Clock, SystemClock}, compat::{ CompatAccessToken, CompatRefreshToken, CompatRefreshTokenState, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, ToScopeTokenError, @@ -42,9 +47,10 @@ pub use self::{ UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction, - UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderResponseMode, UpstreamOAuthProviderSubjectPreference, - UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderLocalpartPreference, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderOnConflict, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode, + UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProviderTokenAuthMethod, }, user_agent::{DeviceType, UserAgent}, users::{ @@ -52,4 +58,6 @@ pub use self::{ UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, }, + utils::{BoxClock, BoxRng}, + version::AppVersion, }; diff --git a/crates/data-model/src/oauth2/authorization_grant.rs b/crates/data-model/src/oauth2/authorization_grant.rs index 1d71f0170..738277b84 100644 --- a/crates/data-model/src/oauth2/authorization_grant.rs +++ b/crates/data-model/src/oauth2/authorization_grant.rs @@ -1,8 +1,10 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::str::FromStr as _; use chrono::{DateTime, Utc}; use mas_iana::oauth::PkceCodeChallengeMethod; @@ -142,6 +144,7 @@ impl AuthorizationGrantStage { pub enum LoginHint<'a> { MXID(&'a UserId), + Email(lettre::Address), None, } @@ -172,33 +175,28 @@ impl std::ops::Deref for AuthorizationGrant { } impl AuthorizationGrant { + /// Parse a `login_hint` + /// + /// Returns `LoginHint::MXID` for valid mxid 'mxid:@john.doe:example.com' + /// + /// Returns `LoginHint::Email` for valid email 'john.doe@example.com' + /// + /// Otherwise returns `LoginHint::None` #[must_use] - pub fn parse_login_hint(&self, homeserver: &str) -> LoginHint { + pub fn parse_login_hint(&self, homeserver: &str) -> LoginHint<'_> { let Some(login_hint) = &self.login_hint else { return LoginHint::None; }; - // Return none if the format is incorrect - let Some((prefix, value)) = login_hint.split_once(':') else { - return LoginHint::None; - }; - - match prefix { - "mxid" => { - // Instead of erroring just return none - let Ok(mxid) = <&UserId>::try_from(value) else { - return LoginHint::None; - }; - - // Only handle MXIDs for current homeserver - if mxid.server_name() != homeserver { - return LoginHint::None; - } - - LoginHint::MXID(mxid) - } - // Unknown hint type, treat as none - _ => LoginHint::None, + if let Some(value) = login_hint.strip_prefix("mxid:") + && let Ok(mxid) = <&UserId>::try_from(value) + && mxid.server_name() == homeserver + { + LoginHint::MXID(mxid) + } else if let Ok(email) = lettre::Address::from_str(login_hint) { + LoginHint::Email(email) + } else { + LoginHint::None } } @@ -271,17 +269,15 @@ impl AuthorizationGrant { #[cfg(test)] mod tests { - use rand::thread_rng; + use rand::SeedableRng; use super::*; + use crate::clock::{Clock, MockClock}; #[test] fn no_login_hint() { - #[allow(clippy::disallowed_methods)] - let mut rng = thread_rng(); - - #[allow(clippy::disallowed_methods)] - let now = Utc::now(); + let now = MockClock::default().now(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); let grant = AuthorizationGrant { login_hint: None, @@ -295,11 +291,8 @@ mod tests { #[test] fn valid_login_hint() { - #[allow(clippy::disallowed_methods)] - let mut rng = thread_rng(); - - #[allow(clippy::disallowed_methods)] - let now = Utc::now(); + let now = MockClock::default().now(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); let grant = AuthorizationGrant { login_hint: Some(String::from("mxid:@example-user:example.com")), @@ -312,12 +305,24 @@ mod tests { } #[test] - fn invalid_login_hint() { - #[allow(clippy::disallowed_methods)] - let mut rng = thread_rng(); + fn valid_login_hint_with_email() { + let now = MockClock::default().now(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); - #[allow(clippy::disallowed_methods)] - let now = Utc::now(); + let grant = AuthorizationGrant { + login_hint: Some(String::from("example@user")), + ..AuthorizationGrant::sample(now, &mut rng) + }; + + let hint = grant.parse_login_hint("example.com"); + + assert!(matches!(hint, LoginHint::Email(email) if email.to_string() == "example@user")); + } + + #[test] + fn invalid_login_hint() { + let now = MockClock::default().now(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); let grant = AuthorizationGrant { login_hint: Some(String::from("example-user")), @@ -331,11 +336,8 @@ mod tests { #[test] fn valid_login_hint_for_wrong_homeserver() { - #[allow(clippy::disallowed_methods)] - let mut rng = thread_rng(); - - #[allow(clippy::disallowed_methods)] - let now = Utc::now(); + let now = MockClock::default().now(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); let grant = AuthorizationGrant { login_hint: Some(String::from("mxid:@example-user:matrix.org")), @@ -349,11 +351,8 @@ mod tests { #[test] fn unknown_login_hint_type() { - #[allow(clippy::disallowed_methods)] - let mut rng = thread_rng(); - - #[allow(clippy::disallowed_methods)] - let now = Utc::now(); + let now = MockClock::default().now(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); let grant = AuthorizationGrant { login_hint: Some(String::from("something:anything")), diff --git a/crates/data-model/src/oauth2/client.rs b/crates/data-model/src/oauth2/client.rs index c184d6fce..ce28445f3 100644 --- a/crates/data-model/src/oauth2/client.rs +++ b/crates/data-model/src/oauth2/client.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs index 794cc460b..aaf7df594 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; diff --git a/crates/data-model/src/oauth2/mod.rs b/crates/data-model/src/oauth2/mod.rs index 0126392c1..6221a32fc 100644 --- a/crates/data-model/src/oauth2/mod.rs +++ b/crates/data-model/src/oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod authorization_grant; mod client; diff --git a/crates/data-model/src/oauth2/session.rs b/crates/data-model/src/oauth2/session.rs index 8a55aa863..c6c9346e9 100644 --- a/crates/data-model/src/oauth2/session.rs +++ b/crates/data-model/src/oauth2/session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; diff --git a/crates/data-model/src/personal/mod.rs b/crates/data-model/src/personal/mod.rs new file mode 100644 index 000000000..1142fea76 --- /dev/null +++ b/crates/data-model/src/personal/mod.rs @@ -0,0 +1,32 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +pub mod session; + +use chrono::{DateTime, Utc}; +use ulid::Ulid; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonalAccessToken { + pub id: Ulid, + pub session_id: Ulid, + pub created_at: DateTime, + pub expires_at: Option>, + pub revoked_at: Option>, +} + +impl PersonalAccessToken { + #[must_use] + pub fn is_valid(&self, now: DateTime) -> bool { + if self.revoked_at.is_some() { + return false; + } + if let Some(expires_at) = self.expires_at { + expires_at > now + } else { + true + } + } +} diff --git a/crates/data-model/src/personal/session.rs b/crates/data-model/src/personal/session.rs new file mode 100644 index 000000000..f3c8d34f9 --- /dev/null +++ b/crates/data-model/src/personal/session.rs @@ -0,0 +1,141 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use oauth2_types::scope::Scope; +use serde::Serialize; +use ulid::Ulid; + +use crate::{Client, Device, InvalidTransitionError, User}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub enum SessionState { + #[default] + Valid, + Revoked { + revoked_at: DateTime, + }, +} + +impl SessionState { + /// Returns `true` if the session state is [`Valid`]. + /// + /// [`Valid`]: SessionState::Valid + #[must_use] + pub fn is_valid(&self) -> bool { + matches!(self, Self::Valid) + } + + /// Returns `true` if the session state is [`Revoked`]. + /// + /// [`Revoked`]: SessionState::Revoked + #[must_use] + pub fn is_revoked(&self) -> bool { + matches!(self, Self::Revoked { .. }) + } + + /// Transitions the session state to [`Revoked`]. + /// + /// # Parameters + /// + /// * `revoked_at` - The time at which the session was revoked. + /// + /// # Errors + /// + /// Returns an error if the session state is already [`Revoked`]. + /// + /// [`Revoked`]: SessionState::Revoked + pub fn revoke(self, revoked_at: DateTime) -> Result { + match self { + Self::Valid => Ok(Self::Revoked { revoked_at }), + Self::Revoked { .. } => Err(InvalidTransitionError), + } + } + + /// Returns the time the session was revoked, if any + /// + /// Returns `None` if the session is still [`Valid`]. + /// + /// [`Valid`]: SessionState::Valid + #[must_use] + pub fn revoked_at(&self) -> Option> { + match self { + Self::Valid => None, + Self::Revoked { revoked_at } => Some(*revoked_at), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PersonalSession { + pub id: Ulid, + pub state: SessionState, + pub owner: PersonalSessionOwner, + pub actor_user_id: Ulid, + pub human_name: String, + /// The scope for the session, identical to OAuth 2 sessions. + /// May or may not include a device scope + /// (personal sessions can be deviceless). + pub scope: Scope, + pub created_at: DateTime, + pub last_active_at: Option>, + pub last_active_ip: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)] +pub enum PersonalSessionOwner { + /// The personal session is owned by the user with the given `user_id`. + User(Ulid), + /// The personal session is owned by the OAuth 2 Client with the given + /// `oauth2_client_id`. + OAuth2Client(Ulid), +} + +impl<'a> From<&'a User> for PersonalSessionOwner { + fn from(value: &'a User) -> Self { + PersonalSessionOwner::User(value.id) + } +} + +impl<'a> From<&'a Client> for PersonalSessionOwner { + fn from(value: &'a Client) -> Self { + PersonalSessionOwner::OAuth2Client(value.id) + } +} + +impl std::ops::Deref for PersonalSession { + type Target = SessionState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl PersonalSession { + /// Marks the session as revoked. + /// + /// # Parameters + /// + /// * `revoked_at` - The time at which the session was finished. + /// + /// # Errors + /// + /// Returns an error if the session is already finished. + pub fn finish(mut self, revoked_at: DateTime) -> Result { + self.state = self.state.revoke(revoked_at)?; + Ok(self) + } + + /// Returns whether the scope of this session contains a device scope; + /// in other words: whether this session has a device. + #[must_use] + pub fn has_device(&self) -> bool { + self.scope + .iter() + .any(|scope_token| Device::from_scope_token(scope_token).is_some()) + } +} diff --git a/crates/data-model/src/policy_data.rs b/crates/data-model/src/policy_data.rs index 8836c2c0c..b732aca8d 100644 --- a/crates/data-model/src/policy_data.rs +++ b/crates/data-model/src/policy_data.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use serde::Serialize; diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index c441ba06f..9622203ad 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::Duration; use url::Url; @@ -64,6 +64,9 @@ pub struct SiteConfig { /// Whether password registration is enabled. pub password_registration_enabled: bool, + /// Whether a valid email address is required for password registrations. + pub password_registration_email_required: bool, + /// Whether registration tokens are required for password registrations. pub registration_token_required: bool, diff --git a/crates/data-model/src/tokens.rs b/crates/data-model/src/tokens.rs index a98ba94e3..bd34c5000 100644 --- a/crates/data-model/src/tokens.rs +++ b/crates/data-model/src/tokens.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use base64ct::{Base64UrlUnpadded, Encoding}; use chrono::{DateTime, Utc}; @@ -240,6 +240,9 @@ pub enum TokenType { /// A legacy refresh token CompatRefreshToken, + + /// A personal access token. + PersonalAccessToken, } impl std::fmt::Display for TokenType { @@ -249,6 +252,7 @@ impl std::fmt::Display for TokenType { TokenType::RefreshToken => write!(f, "refresh token"), TokenType::CompatAccessToken => write!(f, "compat access token"), TokenType::CompatRefreshToken => write!(f, "compat refresh token"), + TokenType::PersonalAccessToken => write!(f, "personal access token"), } } } @@ -260,6 +264,7 @@ impl TokenType { TokenType::RefreshToken => "mar", TokenType::CompatAccessToken => "mct", TokenType::CompatRefreshToken => "mcr", + TokenType::PersonalAccessToken => "mpt", } } @@ -269,6 +274,7 @@ impl TokenType { "mar" => Some(TokenType::RefreshToken), "mct" | "syt" => Some(TokenType::CompatAccessToken), "mcr" | "syr" => Some(TokenType::CompatRefreshToken), + "mpt" => Some(TokenType::PersonalAccessToken), _ => None, } } @@ -335,7 +341,9 @@ impl PartialEq for TokenType { matches!( (self, other), ( - TokenType::AccessToken | TokenType::CompatAccessToken, + TokenType::AccessToken + | TokenType::CompatAccessToken + | TokenType::PersonalAccessToken, OAuthTokenTypeHint::AccessToken ) | ( TokenType::RefreshToken | TokenType::CompatRefreshToken, diff --git a/crates/data-model/src/upstream_oauth2/link.rs b/crates/data-model/src/upstream_oauth2/link.rs index e932b7384..421793ce2 100644 --- a/crates/data-model/src/upstream_oauth2/link.rs +++ b/crates/data-model/src/upstream_oauth2/link.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use serde::Serialize; diff --git a/crates/data-model/src/upstream_oauth2/mod.rs b/crates/data-model/src/upstream_oauth2/mod.rs index 8f4228839..563716568 100644 --- a/crates/data-model/src/upstream_oauth2/mod.rs +++ b/crates/data-model/src/upstream_oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod link; mod provider; @@ -15,7 +15,9 @@ pub use self::{ DiscoveryMode as UpstreamOAuthProviderDiscoveryMode, ImportAction as UpstreamOAuthProviderImportAction, ImportPreference as UpstreamOAuthProviderImportPreference, - PkceMode as UpstreamOAuthProviderPkceMode, + LocalpartPreference as UpstreamOAuthProviderLocalpartPreference, + OnBackchannelLogout as UpstreamOAuthProviderOnBackchannelLogout, + OnConflict as UpstreamOAuthProviderOnConflict, PkceMode as UpstreamOAuthProviderPkceMode, ResponseMode as UpstreamOAuthProviderResponseMode, SubjectPreference as UpstreamOAuthProviderSubjectPreference, TokenAuthMethod as UpstreamOAuthProviderTokenAuthMethod, UpstreamOAuthProvider, diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index 7362d807b..be42cb5a5 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use mas_iana::jose::JsonWebSignatureAlg; @@ -216,6 +216,48 @@ impl std::str::FromStr for TokenAuthMethod { #[error("Invalid upstream OAuth 2.0 token auth method: {0}")] pub struct InvalidUpstreamOAuth2TokenAuthMethod(String); +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OnBackchannelLogout { + DoNothing, + LogoutBrowserOnly, + LogoutAll, +} + +impl OnBackchannelLogout { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::DoNothing => "do_nothing", + Self::LogoutBrowserOnly => "logout_browser_only", + Self::LogoutAll => "logout_all", + } + } +} + +impl std::fmt::Display for OnBackchannelLogout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for OnBackchannelLogout { + type Err = InvalidUpstreamOAuth2OnBackchannelLogout; + + fn from_str(s: &str) -> Result { + match s { + "do_nothing" => Ok(Self::DoNothing), + "logout_browser_only" => Ok(Self::LogoutBrowserOnly), + "logout_all" => Ok(Self::LogoutAll), + s => Err(InvalidUpstreamOAuth2OnBackchannelLogout(s.to_owned())), + } + } +} + +#[derive(Debug, Clone, Error)] +#[error("Invalid upstream OAuth 2.0 'on backchannel logout': {0}")] +pub struct InvalidUpstreamOAuth2OnBackchannelLogout(String); + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UpstreamOAuthProvider { pub id: Ulid, @@ -242,11 +284,12 @@ pub struct UpstreamOAuthProvider { pub claims_imports: ClaimsImports, pub additional_authorization_parameters: Vec<(String, String)>, pub forward_login_hint: bool, + pub on_backchannel_logout: OnBackchannelLogout, } impl PartialOrd for UpstreamOAuthProvider { fn partial_cmp(&self, other: &Self) -> Option { - Some(self.id.cmp(&other.id)) + Some(self.cmp(other)) } } @@ -270,7 +313,7 @@ pub struct ClaimsImports { pub subject: SubjectPreference, #[serde(default)] - pub localpart: ImportPreference, + pub localpart: LocalpartPreference, #[serde(default)] pub displayname: ImportPreference, @@ -289,6 +332,26 @@ pub struct SubjectPreference { pub template: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LocalpartPreference { + #[serde(default)] + pub action: ImportAction, + + #[serde(default)] + pub template: Option, + + #[serde(default)] + pub on_conflict: OnConflict, +} + +impl std::ops::Deref for LocalpartPreference { + type Target = ImportAction; + + fn deref(&self) -> &Self::Target { + &self.action + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ImportPreference { #[serde(default)] @@ -325,7 +388,7 @@ pub enum ImportAction { impl ImportAction { #[must_use] - pub fn is_forced(&self) -> bool { + pub fn is_forced_or_required(&self) -> bool { matches!(self, Self::Force | Self::Require) } @@ -348,3 +411,15 @@ impl ImportAction { } } } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum OnConflict { + /// Fails the upstream OAuth 2.0 login + #[default] + Fail, + + /// Adds the upstream account link, regardless of whether there is an + /// existing link or not + Add, +} diff --git a/crates/data-model/src/upstream_oauth2/session.rs b/crates/data-model/src/upstream_oauth2/session.rs index c5b45234c..e7dad7132 100644 --- a/crates/data-model/src/upstream_oauth2/session.rs +++ b/crates/data-model/src/upstream_oauth2/session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use serde::Serialize; @@ -19,6 +19,7 @@ pub enum UpstreamOAuthAuthorizationSessionState { completed_at: DateTime, link_id: Ulid, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, }, @@ -27,6 +28,7 @@ pub enum UpstreamOAuthAuthorizationSessionState { consumed_at: DateTime, link_id: Ulid, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, }, @@ -35,6 +37,7 @@ pub enum UpstreamOAuthAuthorizationSessionState { consumed_at: Option>, unlinked_at: DateTime, id_token: Option, + id_token_claims: Option, }, } @@ -52,6 +55,7 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at: DateTime, link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result { @@ -60,6 +64,7 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at, link_id: link.id, id_token, + id_token_claims, extra_callback_parameters, userinfo, }), @@ -83,6 +88,7 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at, link_id, id_token, + id_token_claims, extra_callback_parameters, userinfo, } => Ok(Self::Consumed { @@ -90,6 +96,7 @@ impl UpstreamOAuthAuthorizationSessionState { link_id, consumed_at, id_token, + id_token_claims, extra_callback_parameters, userinfo, }), @@ -146,6 +153,29 @@ impl UpstreamOAuthAuthorizationSessionState { } } + /// Get the ID token claims for the upstream OAuth 2.0 authorization + /// session. + /// + /// Returns `None` if the upstream OAuth 2.0 authorization session state is + /// not [`Pending`]. + /// + /// [`Pending`]: UpstreamOAuthAuthorizationSessionState::Pending + #[must_use] + pub fn id_token_claims(&self) -> Option<&serde_json::Value> { + match self { + Self::Pending => None, + Self::Completed { + id_token_claims, .. + } + | Self::Consumed { + id_token_claims, .. + } + | Self::Unlinked { + id_token_claims, .. + } => id_token_claims.as_ref(), + } + } + /// Get the extra query parameters that were sent to the upstream provider. /// /// Returns `None` if the upstream OAuth 2.0 authorization session state is @@ -277,6 +307,7 @@ impl UpstreamOAuthAuthorizationSession { completed_at: DateTime, link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result { @@ -284,6 +315,7 @@ impl UpstreamOAuthAuthorizationSession { completed_at, link, id_token, + id_token_claims, extra_callback_parameters, userinfo, )?; diff --git a/crates/data-model/src/user_agent.rs b/crates/data-model/src/user_agent.rs index 2ac4b06bd..d0e930586 100644 --- a/crates/data-model/src/user_agent.rs +++ b/crates/data-model/src/user_agent.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; @@ -88,32 +88,31 @@ impl UserAgent { #[must_use] pub fn parse(user_agent: String) -> Self { - if !user_agent.contains("Mozilla/") { - if let Some((name, version, model, os, os_version)) = + if !user_agent.contains("Mozilla/") + && let Some((name, version, model, os, os_version)) = UserAgent::parse_custom(&user_agent) - { - let mut device_type = DeviceType::Unknown; + { + let mut device_type = DeviceType::Unknown; - // Handle mobile simple mobile devices - if os == "Android" || os == "iOS" { - device_type = DeviceType::Mobile; - } - - // Handle iPads - if model.contains("iPad") { - device_type = DeviceType::Tablet; - } - - return Self { - name: Some(name.to_owned()), - version: Some(version.to_owned()), - os: Some(os.to_owned()), - os_version: os_version.map(std::borrow::ToOwned::to_owned), - model: Some(model.to_owned()), - device_type, - raw: user_agent, - }; + // Handle mobile simple mobile devices + if os == "Android" || os == "iOS" { + device_type = DeviceType::Mobile; } + + // Handle iPads + if model.contains("iPad") { + device_type = DeviceType::Tablet; + } + + return Self { + name: Some(name.to_owned()), + version: Some(version.to_owned()), + os: Some(os.to_owned()), + os_version: os_version.map(std::borrow::ToOwned::to_owned), + model: Some(model.to_owned()), + device_type, + raw: user_agent, + }; } let mut model = None; @@ -205,11 +204,11 @@ impl UserAgent { } // Special handling for Electron applications e.g. Element Desktop - if user_agent.contains("Electron/") { - if let Some(app) = UserAgent::parse_electron(&user_agent) { - result.name = app.0; - result.version = app.1; - } + if user_agent.contains("Electron/") + && let Some(app) = UserAgent::parse_electron(&user_agent) + { + result.name = app.0; + result.version = app.1; } Self { diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index fc6ed2695..7c7da6293 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; @@ -21,6 +21,7 @@ pub struct User { pub locked_at: Option>, pub deactivated_at: Option>, pub can_request_admin: bool, + pub is_guest: bool, } impl User { @@ -29,6 +30,20 @@ impl User { pub fn is_valid(&self) -> bool { self.locked_at.is_none() && self.deactivated_at.is_none() } + + /// Returns `true` if the user is a valid actor, for example + /// of a personal session. + /// + /// Currently: this is `true` unless the user is deactivated. + /// + /// This is a weaker form of validity: `is_valid` always implies + /// `is_valid_actor`, but some users (currently: locked users) + /// can be valid actors for personal sessions but aren't valid + /// except through administrative access. + #[must_use] + pub fn is_valid_actor(&self) -> bool { + self.deactivated_at.is_none() + } } impl User { @@ -43,6 +58,7 @@ impl User { locked_at: None, deactivated_at: None, can_request_admin: false, + is_guest: false, }] } } @@ -223,17 +239,17 @@ impl UserRegistrationToken { } // Check if expired - if let Some(expires_at) = self.expires_at { - if now >= expires_at { - return false; - } + if let Some(expires_at) = self.expires_at + && now >= expires_at + { + return false; } // Check if usage limit exceeded - if let Some(usage_limit) = self.usage_limit { - if self.times_used >= usage_limit { - return false; - } + if let Some(usage_limit) = self.usage_limit + && self.times_used >= usage_limit + { + return false; } true diff --git a/crates/data-model/src/utils.rs b/crates/data-model/src/utils.rs new file mode 100644 index 000000000..bd3b15de6 --- /dev/null +++ b/crates/data-model/src/utils.rs @@ -0,0 +1,13 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use rand_chacha::rand_core::CryptoRngCore; + +use crate::clock::Clock; + +/// A boxed [`Clock`] +pub type BoxClock = Box; +/// A boxed random number generator +pub type BoxRng = Box; diff --git a/crates/data-model/src/version.rs b/crates/data-model/src/version.rs new file mode 100644 index 000000000..86d890fc1 --- /dev/null +++ b/crates/data-model/src/version.rs @@ -0,0 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +/// A structure which holds information about the running version of the app +#[derive(Debug, Clone, Copy)] +pub struct AppVersion(pub &'static str); diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index 7b8ff94c4..eba4ea6fa 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-email" version.workspace = true diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 49e940638..ee731f744 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Helps sending emails to users, with different email backends diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index 443859a3b..d31fe96ab 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Send emails to users diff --git a/crates/email/src/transport.rs b/crates/email/src/transport.rs index 21291a1b1..004844ab1 100644 --- a/crates/email/src/transport.rs +++ b/crates/email/src/transport.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Email transport backends @@ -36,7 +36,9 @@ pub struct Transport { inner: Arc, } +#[derive(Default)] enum TransportInner { + #[default] Blackhole, Smtp(AsyncSmtpTransport), Sendmail(AsyncSendmailTransport), @@ -113,12 +115,6 @@ impl Transport { } } -impl Default for TransportInner { - fn default() -> Self { - Self::Blackhole - } -} - #[derive(Debug, Error)] #[error(transparent)] pub enum Error { diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 4bda28ced..57f391854 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-handlers" version.workspace = true @@ -67,6 +72,7 @@ mas-axum-utils.workspace = true mas-config.workspace = true mas-context.workspace = true mas-data-model.workspace = true +mas-email.workspace = true mas-http.workspace = true mas-i18n.workspace = true mas-iana.workspace = true @@ -78,6 +84,7 @@ mas-policy.workspace = true mas-router.workspace = true mas-storage.workspace = true mas-storage-pg.workspace = true +mas-tasks.workspace = true mas-templates.workspace = true oauth2-types.workspace = true zxcvbn.workspace = true diff --git a/crates/handlers/src/activity_tracker/bound.rs b/crates/handlers/src/activity_tracker/bound.rs index c4e2f7cce..8f7acbdde 100644 --- a/crates/handlers/src/activity_tracker/bound.rs +++ b/crates/handlers/src/activity_tracker/bound.rs @@ -1,13 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; -use mas_data_model::{BrowserSession, CompatSession, Session}; -use mas_storage::Clock; +use mas_data_model::{ + BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession, +}; use crate::activity_tracker::ActivityTracker; @@ -38,6 +39,13 @@ impl Bound { .await; } + /// Record activity in a personal session. + pub async fn record_personal_session(&self, clock: &dyn Clock, session: &PersonalSession) { + self.tracker + .record_personal_session(clock, session, self.ip) + .await; + } + /// Record activity in a compatibility session. pub async fn record_compat_session(&self, clock: &dyn Clock, session: &CompatSession) { self.tracker diff --git a/crates/handlers/src/activity_tracker/mod.rs b/crates/handlers/src/activity_tracker/mod.rs index 56785e236..e1c6b976f 100644 --- a/crates/handlers/src/activity_tracker/mod.rs +++ b/crates/handlers/src/activity_tracker/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod bound; mod worker; @@ -10,8 +10,10 @@ mod worker; use std::net::IpAddr; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, CompatSession, Session}; -use mas_storage::{BoxRepositoryFactory, Clock}; +use mas_data_model::{ + BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession, +}; +use mas_storage::BoxRepositoryFactory; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use ulid::Ulid; @@ -24,6 +26,8 @@ static MESSAGE_QUEUE_SIZE: usize = 1000; enum SessionKind { OAuth2, Compat, + /// Session associated with personal access tokens + Personal, Browser, } @@ -32,6 +36,7 @@ impl SessionKind { match self { SessionKind::OAuth2 => "oauth2", SessionKind::Compat => "compat", + SessionKind::Personal => "personal", SessionKind::Browser => "browser", } } @@ -108,6 +113,28 @@ impl ActivityTracker { } } + /// Record activity in a personal session. + pub async fn record_personal_session( + &self, + clock: &dyn Clock, + session: &PersonalSession, + ip: Option, + ) { + let res = self + .channel + .send(Message::Record { + kind: SessionKind::Personal, + id: session.id, + date_time: clock.now(), + ip, + }) + .await; + + if let Err(e) = res { + tracing::error!("Failed to record Personal session: {}", e); + } + } + /// Record activity in a compat session. pub async fn record_compat_session( &self, diff --git a/crates/handlers/src/activity_tracker/worker.rs b/crates/handlers/src/activity_tracker/worker.rs index 4787964ee..9405eab41 100644 --- a/crates/handlers/src/activity_tracker/worker.rs +++ b/crates/handlers/src/activity_tracker/worker.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, net::IpAddr}; @@ -224,6 +224,7 @@ impl Worker { let mut browser_sessions = Vec::new(); let mut oauth2_sessions = Vec::new(); let mut compat_sessions = Vec::new(); + let mut personal_sessions = Vec::new(); for ((kind, id), record) in pending_records { match kind { @@ -236,6 +237,9 @@ impl Worker { SessionKind::Compat => { compat_sessions.push((*id, record.end_time, record.ip)); } + SessionKind::Personal => { + personal_sessions.push((*id, record.end_time, record.ip)); + } } } @@ -253,6 +257,9 @@ impl Worker { repo.compat_session() .record_batch_activity(compat_sessions) .await?; + repo.personal_session() + .record_batch_activity(personal_sessions) + .await?; repo.save().await?; self.pending_records.clear(); diff --git a/crates/handlers/src/admin/call_context.rs b/crates/handlers/src/admin/call_context.rs index 95340b160..1cffe682e 100644 --- a/crates/handlers/src/admin/call_context.rs +++ b/crates/handlers/src/admin/call_context.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::convert::Infallible; @@ -16,8 +16,12 @@ use axum_extra::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_data_model::{Session, User}; -use mas_storage::{BoxClock, BoxRepository, RepositoryError}; +use mas_data_model::{ + BoxClock, Session, TokenFormatError, TokenType, User, + personal::session::{PersonalSession, PersonalSessionOwner}, +}; +use mas_storage::{BoxRepository, RepositoryError}; +use oauth2_types::scope::Scope; use ulid::Ulid; use super::response::ErrorResponse; @@ -41,6 +45,10 @@ pub enum Rejection { #[error("Invalid repository operation")] Repository(#[from] RepositoryError), + /// The access token was not of the correct type for the Admin API + #[error("Invalid type of access token")] + InvalidAccessTokenType(#[from] Option), + /// The access token could not be found in the database #[error("Unknown access token")] UnknownAccessToken, @@ -90,7 +98,8 @@ impl IntoResponse for Rejection { | Rejection::TokenExpired | Rejection::SessionRevoked | Rejection::UserLocked - | Rejection::MissingScope => StatusCode::UNAUTHORIZED, + | Rejection::MissingScope + | Rejection::InvalidAccessTokenType(_) => StatusCode::UNAUTHORIZED, Rejection::RepositorySetup(_) | Rejection::Repository(_) @@ -113,7 +122,7 @@ pub struct CallContext { pub repo: BoxRepository, pub clock: BoxClock, pub user: Option, - pub session: Session, + pub session: CallerSession, } impl FromRequestParts for CallContext @@ -154,56 +163,126 @@ where })?; let token = token.token(); + let token_type = TokenType::check(token)?; - // Look for the access token in the database - let token = repo - .oauth2_access_token() - .find_by_token(token) - .await? - .ok_or(Rejection::UnknownAccessToken)?; + let session = match token_type { + TokenType::AccessToken => { + // Look for the access token in the database + let token = repo + .oauth2_access_token() + .find_by_token(token) + .await? + .ok_or(Rejection::UnknownAccessToken)?; - // Look for the associated session in the database - let session = repo - .oauth2_session() - .lookup(token.session_id) - .await? - .ok_or_else(|| Rejection::LoadSession(token.session_id))?; + // Look for the associated session in the database + let session = repo + .oauth2_session() + .lookup(token.session_id) + .await? + .ok_or_else(|| Rejection::LoadSession(token.session_id))?; - // Record the activity on the session - activity_tracker - .record_oauth2_session(&clock, &session) - .await; + if !session.is_valid() { + return Err(Rejection::SessionRevoked); + } + + if !token.is_valid(clock.now()) { + return Err(Rejection::TokenExpired); + } + + // Record the activity on the session + activity_tracker + .record_oauth2_session(&clock, &session) + .await; + + CallerSession::OAuth2Session(session) + } + TokenType::PersonalAccessToken => { + // Look for the access token in the database + let token = repo + .personal_access_token() + .find_by_token(token) + .await? + .ok_or(Rejection::UnknownAccessToken)?; + + // Look for the associated session in the database + let session = repo + .personal_session() + .lookup(token.session_id) + .await? + .ok_or_else(|| Rejection::LoadSession(token.session_id))?; + + if !session.is_valid() { + return Err(Rejection::SessionRevoked); + } + + if !token.is_valid(clock.now()) { + return Err(Rejection::TokenExpired); + } + + // Check the validity of the owner of the personal session + match session.owner { + PersonalSessionOwner::User(owner_user_id) => { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or_else(|| Rejection::LoadUser(owner_user_id))?; + if !owner_user.is_valid() { + return Err(Rejection::UserLocked); + } + } + PersonalSessionOwner::OAuth2Client(_) => { + // nop: Client owners are always valid + } + } + + // Record the activity on the session + activity_tracker + .record_personal_session(&clock, &session) + .await; + + CallerSession::PersonalSession(session) + } + _other => { + return Err(Rejection::InvalidAccessTokenType(None)); + } + }; // Load the user if there is one - let user = if let Some(user_id) = session.user_id { + let user = if let Some(user_id) = session.user_id() { let user = repo .user() .lookup(user_id) .await? .ok_or_else(|| Rejection::LoadUser(user_id))?; + + match session { + CallerSession::OAuth2Session(_) => { + // For OAuth2 sessions: check that the user is valid enough + // to be a user. + if !user.is_valid() { + return Err(Rejection::UserLocked); + } + } + CallerSession::PersonalSession(_) => { + // For personal sessions: check that the actor is valid enough + // to be an actor. + if !user.is_valid_actor() { + return Err(Rejection::UserLocked); + } + } + } + Some(user) } else { + // Double check we're not using a PersonalSession + assert!(matches!(session, CallerSession::OAuth2Session(_))); None }; - // If there is a user for this session, check that it is not locked - if let Some(user) = &user { - if !user.is_valid() { - return Err(Rejection::UserLocked); - } - } - - if !session.is_valid() { - return Err(Rejection::SessionRevoked); - } - - if !token.is_valid(clock.now()) { - return Err(Rejection::TokenExpired); - } - // For now, we only check that the session has the admin scope // Later we might want to check other route-specific scopes - if !session.scope.contains("urn:mas:admin") { + if !session.scope().contains("urn:mas:admin") { return Err(Rejection::MissingScope); } @@ -215,3 +294,26 @@ where }) } } + +/// The session representing the caller of the Admin API; +/// could either be an OAuth session or a personal session. +pub enum CallerSession { + OAuth2Session(Session), + PersonalSession(PersonalSession), +} + +impl CallerSession { + pub fn scope(&self) -> &Scope { + match self { + CallerSession::OAuth2Session(session) => &session.scope, + CallerSession::PersonalSession(session) => &session.scope, + } + } + + pub fn user_id(&self) -> Option { + match self { + CallerSession::OAuth2Session(session) => session.user_id, + CallerSession::PersonalSession(session) => Some(session.actor_user_id), + } + } +} diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 1938980dd..f69a6fb17 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; @@ -20,6 +20,7 @@ use axum::{ use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use indexmap::IndexMap; use mas_axum_utils::InternalError; +use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_http::CorsLayerExt; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; @@ -27,7 +28,6 @@ use mas_router::{ ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute, UrlBuilder, }; -use mas_storage::BoxRng; use mas_templates::{ApiDocContext, Templates}; use schemars::transform::{AddNullable, RecursiveTransform}; use tower_http::cors::{Any, CorsLayer}; @@ -44,6 +44,11 @@ use crate::passwords::PasswordManager; fn finish(t: TransformOpenApi) -> TransformOpenApi { t.title("Matrix Authentication Service admin API") + .tag(Tag { + name: "server".to_owned(), + description: Some("Information about the server".to_owned()), + ..Tag::default() + }) .tag(Tag { name: "compat-session".to_owned(), description: Some("Manage compatibility sessions from legacy clients".to_owned()), @@ -87,6 +92,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { ), ..Default::default() }) + .tag(Tag { + name: "upstream-oauth-provider".to_owned(), + description: Some("Manage upstream OAuth 2.0 providers".to_owned()), + ..Tag::default() + }) .security_scheme("oauth2", oauth_security_scheme(None)) .security_scheme( "token", @@ -154,6 +164,8 @@ where Templates: FromRef, UrlBuilder: FromRef, Arc: FromRef, + SiteConfig: FromRef, + AppVersion: FromRef, { // We *always* want to explicitly set the possible responses, beacuse the // infered ones are not necessarily correct diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 03fd72ad0..7936c02f8 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -1,15 +1,22 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use chrono::{DateTime, Utc}; -use mas_data_model::Device; +use mas_data_model::{ + Device, + personal::{ + PersonalAccessToken as DataModelPersonalAccessToken, + session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner}, + }, +}; use schemars::JsonSchema; use serde::Serialize; +use thiserror::Error; use ulid::Ulid; use url::Url; @@ -52,6 +59,9 @@ pub struct User { /// Whether the user can request admin privileges. admin: bool, + + /// Whether the user was a guest before migrating to MAS, + legacy_guest: bool, } impl User { @@ -65,6 +75,7 @@ impl User { locked_at: None, deactivated_at: None, admin: false, + legacy_guest: false, }, Self { id: Ulid::from_bytes([0x02; 16]), @@ -73,6 +84,7 @@ impl User { locked_at: None, deactivated_at: None, admin: true, + legacy_guest: false, }, Self { id: Ulid::from_bytes([0x03; 16]), @@ -81,6 +93,7 @@ impl User { locked_at: Some(DateTime::default()), deactivated_at: None, admin: false, + legacy_guest: true, }, ] } @@ -95,6 +108,7 @@ impl From for User { locked_at: user.locked_at, deactivated_at: user.deactivated_at, admin: user.can_request_admin, + legacy_guest: user.is_guest, } } } @@ -375,7 +389,7 @@ impl OAuth2Session { user_id: Some(Ulid::from_bytes([0x04; 16])), user_session_id: Some(Ulid::from_bytes([0x05; 16])), client_id: Ulid::from_bytes([0x06; 16]), - scope: "urn:matrix:org.matrix.msc2967.client:api:*".to_owned(), + scope: "urn:matrix:client:api:*".to_owned(), user_agent: Some("Mozilla/5.0".to_owned()), last_active_at: Some(DateTime::default()), last_active_ip: Some("127.0.0.1".parse().unwrap()), @@ -688,3 +702,255 @@ impl UserRegistrationToken { ] } } + +/// An upstream OAuth 2.0 provider +#[derive(Serialize, JsonSchema)] +pub struct UpstreamOAuthProvider { + #[serde(skip)] + id: Ulid, + + /// The OIDC issuer of the provider + issuer: Option, + + /// A human-readable name for the provider + human_name: Option, + + /// A brand identifier, e.g. "apple" or "google" + brand_name: Option, + + /// When the provider was created + created_at: DateTime, + + /// When the provider was disabled. If null, the provider is enabled. + disabled_at: Option>, +} + +impl From for UpstreamOAuthProvider { + fn from(provider: mas_data_model::UpstreamOAuthProvider) -> Self { + Self { + id: provider.id, + issuer: provider.issuer, + human_name: provider.human_name, + brand_name: provider.brand_name, + created_at: provider.created_at, + disabled_at: provider.disabled_at, + } + } +} + +impl Resource for UpstreamOAuthProvider { + const KIND: &'static str = "upstream-oauth-provider"; + const PATH: &'static str = "/api/admin/v1/upstream-oauth-providers"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl UpstreamOAuthProvider { + /// Samples of upstream OAuth 2.0 providers + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_bytes([0x01; 16]), + issuer: Some("https://accounts.google.com".to_owned()), + human_name: Some("Google".to_owned()), + brand_name: Some("google".to_owned()), + created_at: DateTime::default(), + disabled_at: None, + }, + Self { + id: Ulid::from_bytes([0x02; 16]), + issuer: Some("https://appleid.apple.com".to_owned()), + human_name: Some("Apple ID".to_owned()), + brand_name: Some("apple".to_owned()), + created_at: DateTime::default(), + disabled_at: Some(DateTime::default()), + }, + Self { + id: Ulid::from_bytes([0x03; 16]), + issuer: None, + human_name: Some("Custom OAuth Provider".to_owned()), + brand_name: None, + created_at: DateTime::default(), + disabled_at: None, + }, + ] + } +} + +/// An error that shouldn't happen in practice, but suggests database +/// inconsistency. +#[derive(Debug, Error)] +#[error( + "personal session {session_id} in inconsistent state: not revoked but no valid access token" +)] +pub struct InconsistentPersonalSession { + pub session_id: Ulid, +} + +// Note: we don't expose a separate concept of personal access tokens to the +// admin API; we merge the relevant attributes into the personal session. +/// A personal session (session using personal access tokens) +#[derive(Serialize, JsonSchema)] +pub struct PersonalSession { + #[serde(skip)] + id: Ulid, + + /// When the session was created + created_at: DateTime, + + /// When the session was revoked, if applicable + revoked_at: Option>, + + /// The ID of the user who owns this session (if user-owned) + #[schemars(with = "Option")] + owner_user_id: Option, + + /// The ID of the `OAuth2` client that owns this session (if client-owned) + #[schemars(with = "Option")] + owner_client_id: Option, + + /// The ID of the user that the session acts on behalf of + #[schemars(with = "super::schema::Ulid")] + actor_user_id: Ulid, + + /// Human-readable name for the session + human_name: String, + + /// `OAuth2` scopes for this session + scope: String, + + /// When the session was last active + last_active_at: Option>, + + /// IP address of last activity + last_active_ip: Option, + + /// When the current token for this session expires. + /// The session will need to be regenerated, producing a new access token, + /// after this time. + /// None if the current token won't expire or if the session is revoked. + expires_at: Option>, + + /// The actual access token (only returned on creation) + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, +} + +impl + TryFrom<( + DataModelPersonalSession, + Option, + )> for PersonalSession +{ + type Error = InconsistentPersonalSession; + + fn try_from( + (session, token): ( + DataModelPersonalSession, + Option, + ), + ) -> Result { + let expires_at = if let Some(token) = token { + token.expires_at + } else { + if !session.is_revoked() { + // No active token, but the session is not revoked. + return Err(InconsistentPersonalSession { + session_id: session.id, + }); + } + None + }; + + let (owner_user_id, owner_client_id) = match session.owner { + PersonalSessionOwner::User(id) => (Some(id), None), + PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)), + }; + + Ok(Self { + id: session.id, + created_at: session.created_at, + revoked_at: session.revoked_at(), + owner_user_id, + owner_client_id, + actor_user_id: session.actor_user_id, + human_name: session.human_name, + scope: session.scope.to_string(), + last_active_at: session.last_active_at, + last_active_ip: session.last_active_ip, + expires_at, + // If relevant, the caller will populate using `with_token` afterwards. + access_token: None, + }) + } +} + +impl Resource for PersonalSession { + const KIND: &'static str = "personal-session"; + const PATH: &'static str = "/api/admin/v1/personal-sessions"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl PersonalSession { + /// Sample personal sessions for documentation/testing + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14: + * 40:00Z */ + revoked_at: None, + owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()), + owner_client_id: None, + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), + human_name: "Alice's Development Token".to_owned(), + scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */ + last_active_ip: Some("192.168.1.100".parse().unwrap()), + expires_at: None, + access_token: None, + }, + Self { + id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14: + * 41:00Z */ + revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */ + owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()), + owner_client_id: None, + actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(), + human_name: "Bob's Mobile App".to_owned(), + scope: "openid".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */ + last_active_ip: Some("10.0.0.50".parse().unwrap()), + expires_at: None, + access_token: None, + }, + Self { + id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14: + * 42:00Z */ + revoked_at: None, + owner_user_id: None, + owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()), + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), + human_name: "CI/CD Pipeline Token".to_owned(), + scope: "openid urn:mas:admin".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */ + last_active_ip: Some("203.0.113.10".parse().unwrap()), + expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()), + access_token: None, + }, + ] + } + + /// Add the actual token value (for use in creation responses) + pub fn with_token(mut self, access_token: String) -> Self { + self.access_token = Some(access_token); + self + } +} diff --git a/crates/handlers/src/admin/params.rs b/crates/handlers/src/admin/params.rs index 749bc62b3..4b1ccb1de 100644 --- a/crates/handlers/src/admin/params.rs +++ b/crates/handlers/src/admin/params.rs @@ -1,23 +1,21 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // Generated code from schemars violates this rule #![allow(clippy::str_to_string)] -use std::num::NonZeroUsize; +use std::{borrow::Cow, num::NonZeroUsize}; use aide::OperationIo; use axum::{ Json, - extract::{ - FromRequestParts, Path, Query, - rejection::{PathRejection, QueryRejection}, - }, + extract::{FromRequestParts, Path, rejection::PathRejection}, response::IntoResponse, }; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_storage::pagination::PaginationDirection; @@ -64,6 +62,34 @@ impl std::ops::Deref for UlidPathParam { /// The default page size if not specified const DEFAULT_PAGE_SIZE: usize = 10; +#[derive(Deserialize, JsonSchema, Clone, Copy, Default, Debug)] +pub enum IncludeCount { + /// Include the total number of items (default) + #[default] + #[serde(rename = "true")] + True, + + /// Do not include the total number of items + #[serde(rename = "false")] + False, + + /// Only include the total number of items, skip the items themselves + #[serde(rename = "only")] + Only, +} + +impl IncludeCount { + pub(crate) fn add_to_base(self, base: &str) -> Cow<'_, str> { + let separator = if base.contains('?') { '&' } else { '?' }; + match self { + // This is the default, don't add anything + Self::True => Cow::Borrowed(base), + Self::False => format!("{base}{separator}count=false").into(), + Self::Only => format!("{base}{separator}count=only").into(), + } + } +} + #[derive(Deserialize, JsonSchema, Clone, Copy)] struct PaginationParams { /// Retrieve the items before the given ID @@ -83,6 +109,10 @@ struct PaginationParams { /// Retrieve the last N items #[serde(rename = "page[last]")] last: Option, + + /// Include the total number of items. Defaults to `true`. + #[serde(rename = "count")] + include_count: Option, } #[derive(Debug, thiserror::Error)] @@ -107,7 +137,7 @@ impl IntoResponse for PaginationRejection { /// An extractor for pagination parameters in the query string #[derive(OperationIo, Debug, Clone, Copy)] #[aide(input_with = "Query")] -pub struct Pagination(pub mas_storage::Pagination); +pub struct Pagination(pub mas_storage::Pagination, pub IncludeCount); impl FromRequestParts for Pagination { type Rejection = PaginationRejection; @@ -130,11 +160,14 @@ impl FromRequestParts for Pagination { (None, Some(last)) => (PaginationDirection::Backward, last.into()), }; - Ok(Self(mas_storage::Pagination { - before: params.before, - after: params.after, - direction, - count, - })) + Ok(Self( + mas_storage::Pagination { + before: params.before, + after: params.after, + direction, + count, + }, + params.include_count.unwrap_or_default(), + )) } } diff --git a/crates/handlers/src/admin/response.rs b/crates/handlers/src/admin/response.rs index 753260961..257773cd2 100644 --- a/crates/handlers/src/admin/response.rs +++ b/crates/handlers/src/admin/response.rs @@ -1,12 +1,12 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::module_name_repetitions)] -use mas_storage::Pagination; +use mas_storage::{Pagination, pagination::Edge}; use schemars::JsonSchema; use serde::Serialize; use ulid::Ulid; @@ -21,10 +21,12 @@ struct PaginationLinks { self_: String, /// The link to the first page of results - first: String, + #[serde(skip_serializing_if = "Option::is_none")] + first: Option, /// The link to the last page of results - last: String, + #[serde(skip_serializing_if = "Option::is_none")] + last: Option, /// The link to the next page of results /// @@ -42,17 +44,27 @@ struct PaginationLinks { #[derive(Serialize, JsonSchema)] struct PaginationMeta { /// The total number of results - count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + count: Option, +} + +impl PaginationMeta { + fn is_empty(&self) -> bool { + self.count.is_none() + } } /// A top-level response with a page of resources #[derive(Serialize, JsonSchema)] pub struct PaginatedResponse { /// Response metadata + #[serde(skip_serializing_if = "PaginationMeta::is_empty")] + #[schemars(with = "Option")] meta: PaginationMeta, /// The list of resources - data: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>>, /// Related links links: PaginationLinks, @@ -87,22 +99,28 @@ fn url_with_pagination(base: &str, pagination: Pagination) -> String { } impl PaginatedResponse { - pub fn new( + pub fn for_page( page: mas_storage::Page, current_pagination: Pagination, - count: usize, + count: Option, base: &str, ) -> Self { let links = PaginationLinks { self_: url_with_pagination(base, current_pagination), - first: url_with_pagination(base, Pagination::first(current_pagination.count)), - last: url_with_pagination(base, Pagination::last(current_pagination.count)), + first: Some(url_with_pagination( + base, + Pagination::first(current_pagination.count), + )), + last: Some(url_with_pagination( + base, + Pagination::last(current_pagination.count), + )), next: page.has_next_page.then(|| { url_with_pagination( base, current_pagination .clear_before() - .after(page.edges.last().unwrap().id()), + .after(page.edges.last().unwrap().cursor), ) }), prev: if page.has_previous_page { @@ -110,18 +128,38 @@ impl PaginatedResponse { base, current_pagination .clear_after() - .before(page.edges.first().unwrap().id()), + .before(page.edges.first().unwrap().cursor), )) } else { None }, }; - let data = page.edges.into_iter().map(SingleResource::new).collect(); + let data = page + .edges + .into_iter() + .map(SingleResource::from_edge) + .collect(); Self { meta: PaginationMeta { count }, - data, + data: Some(data), + links, + } + } + + pub fn for_count_only(count: usize, base: &str) -> Self { + let links = PaginationLinks { + self_: base.to_owned(), + first: None, + last: None, + next: None, + prev: None, + }; + + Self { + meta: PaginationMeta { count: Some(count) }, + data: None, links, } } @@ -143,6 +181,32 @@ struct SingleResource { /// Related links links: SelfLinks, + + /// Metadata about the resource + #[serde(skip_serializing_if = "SingleResourceMeta::is_empty")] + #[schemars(with = "Option")] + meta: SingleResourceMeta, +} + +/// Metadata associated with a resource +#[derive(Serialize, JsonSchema)] +struct SingleResourceMeta { + /// Information about the pagination of the resource + #[serde(skip_serializing_if = "Option::is_none")] + page: Option, +} + +impl SingleResourceMeta { + fn is_empty(&self) -> bool { + self.page.is_none() + } +} + +/// Pagination metadata for a resource +#[derive(Serialize, JsonSchema)] +struct SingleResourceMetaPage { + /// The cursor of this resource in the paginated result + cursor: String, } impl SingleResource { @@ -153,8 +217,16 @@ impl SingleResource { id: resource.id(), attributes: resource, links: SelfLinks { self_ }, + meta: SingleResourceMeta { page: None }, } } + + fn from_edge(edge: Edge) -> Self { + let cursor = edge.cursor.to_string(); + let mut resource = Self::new(edge.node); + resource.meta.page = Some(SingleResourceMetaPage { cursor }); + resource + } } /// Related links diff --git a/crates/handlers/src/admin/schema.rs b/crates/handlers/src/admin/schema.rs index 2c50dcd26..e305df061 100644 --- a/crates/handlers/src/admin/schema.rs +++ b/crates/handlers/src/admin/schema.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Common schema definitions diff --git a/crates/handlers/src/admin/v1/compat_sessions/finish.rs b/crates/handlers/src/admin/v1/compat_sessions/finish.rs new file mode 100644 index 000000000..df42c2ff9 --- /dev/null +++ b/crates/handlers/src/admin/v1/compat_sessions/finish.rs @@ -0,0 +1,243 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{CompatSession, Resource}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Compatibility session with ID {0} not found")] + NotFound(Ulid), + + #[error("Compatibility session with ID {0} is already finished")] + AlreadyFinished(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishCompatSession") + .summary("Finish a compatibility session") + .description( + "Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.", + ) + .tag("compat-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, finished_session, _] = CompatSession::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/compat-sessions/{id}/finish"), + ); + t.description("Compatibility session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Compatibility session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .compat_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at().is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // Schedule a job to sync the devices of the user with the homeserver + tracing::info!(user.id = %session.user_id, "Scheduling device sync job for user"); + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SyncDevicesJob::new_for_id(session.user_id), + ) + .await?; + + // Finish the session + let session = repo.compat_session().finish(&clock, session).await?; + + // Get the SSO login info for the response + let sso_login = repo.compat_sso_login().find_for_session(&session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + CompatSession::from((session, sso_login)), + format!("/api/admin/v1/compat-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock as _, Device}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/compat-sessions/{}/finish", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + + // Finish the session first + let session = repo + .compat_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/compat-sessions/{}/finish", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!( + "Compatibility session with ID {} is already finished", + session.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/compat-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Compatibility session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/compat_sessions/get.rs b/crates/handlers/src/admin/v1/compat_sessions/get.rs index 3d471d0ce..c77432d07 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/get.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/get.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index adf15d190..b407854f6 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -1,14 +1,11 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{CompatSession, Resource}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -137,16 +134,22 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p let sessions = CompatSession::samples(); let pagination = mas_storage::Pagination::first(sessions.len()); let page = Page { - edges: sessions.into(), + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of compatibility sessions") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), CompatSession::PATH, )) }) @@ -159,10 +162,11 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p #[tracing::instrument(name = "handler.admin.v1.compat_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = CompatSession::PATH); + let base = include_count.add_to_base(&base); let filter = CompatSessionFilter::default(); // Load the user from the filter @@ -206,15 +210,31 @@ pub async fn handler( None => filter, }; - let page = repo.compat_session().list(filter, pagination).await?; - let count = repo.compat_session().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .compat_session() + .list(filter, pagination) + .await? + .map(CompatSession::from); + let count = repo.compat_session().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .compat_session() + .list(filter, pagination) + .await? + .map(CompatSession::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.compat_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(CompatSession::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -299,6 +319,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } } }, { @@ -318,6 +343,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + }, + "meta": { + "page": { + "cursor": "01FSHNCZP0PPF7X0EVMJNECPZW" + } } } ], @@ -362,6 +392,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } } } ], @@ -403,6 +438,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } } } ], @@ -444,6 +484,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + }, + "meta": { + "page": { + "cursor": "01FSHNCZP0PPF7X0EVMJNECPZW" + } } } ], @@ -454,5 +499,155 @@ mod tests { } } "#); + + // Test count=false + let request = Request::get("/api/admin/v1/compat-sessions?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null, + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } + } + }, + { + "type": "compat-session", + "id": "01FSHNCZP0PPF7X0EVMJNECPZW", + "attributes": { + "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4", + "device_id": "ZXyvelQWW9", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:42:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": "2022-01-16T14:43:00Z", + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + }, + "meta": { + "page": { + "cursor": "01FSHNCZP0PPF7X0EVMJNECPZW" + } + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?count=false&page[first]=10", + "first": "/api/admin/v1/compat-sessions?count=false&page[first]=10", + "last": "/api/admin/v1/compat-sessions?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/compat-sessions?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/compat-sessions?count=only" + } + } + "#); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/compat-sessions?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null, + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = + Request::get("/api/admin/v1/compat-sessions?count=only&filter[status]=active") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/compat-sessions?filter[status]=active&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/compat_sessions/mod.rs b/crates/handlers/src/admin/v1/compat_sessions/mod.rs index 23c05c416..db7b17ff5 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/mod.rs @@ -1,12 +1,14 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 02586368b..98f1d10e2 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; @@ -11,33 +11,46 @@ use aide::axum::{ routing::{get_with, post_with}, }; use axum::extract::{FromRef, FromRequestParts}; +use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; -use mas_storage::BoxRng; use super::call_context::CallContext; use crate::passwords::PasswordManager; mod compat_sessions; mod oauth2_sessions; +mod personal_sessions; mod policy_data; +mod site_config; mod upstream_oauth_links; +mod upstream_oauth_providers; mod user_emails; mod user_registration_tokens; mod user_sessions; mod users; +mod version; -#[allow(clippy::too_many_lines)] pub fn router() -> ApiRouter where S: Clone + Send + Sync + 'static, Arc: FromRef, PasswordManager: FromRef, + SiteConfig: FromRef, + AppVersion: FromRef, Arc: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, { ApiRouter::::new() + .api_route( + "/site-config", + get_with(self::site_config::handler, self::site_config::doc), + ) + .api_route( + "/version", + get_with(self::version::handler, self::version::doc), + ) .api_route( "/compat-sessions", get_with(self::compat_sessions::list, self::compat_sessions::list_doc), @@ -46,6 +59,13 @@ where "/compat-sessions/{id}", get_with(self::compat_sessions::get, self::compat_sessions::get_doc), ) + .api_route( + "/compat-sessions/{id}/finish", + post_with( + self::compat_sessions::finish, + self::compat_sessions::finish_doc, + ), + ) .api_route( "/oauth2-sessions", get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc), @@ -54,6 +74,45 @@ where "/oauth2-sessions/{id}", get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc), ) + .api_route( + "/oauth2-sessions/{id}/finish", + post_with( + self::oauth2_sessions::finish, + self::oauth2_sessions::finish_doc, + ), + ) + .api_route( + "/personal-sessions", + get_with( + self::personal_sessions::list, + self::personal_sessions::list_doc, + ) + .post_with( + self::personal_sessions::add, + self::personal_sessions::add_doc, + ), + ) + .api_route( + "/personal-sessions/{id}", + get_with( + self::personal_sessions::get, + self::personal_sessions::get_doc, + ), + ) + .api_route( + "/personal-sessions/{id}/revoke", + post_with( + self::personal_sessions::revoke, + self::personal_sessions::revoke_doc, + ), + ) + .api_route( + "/personal-sessions/{id}/regenerate", + post_with( + self::personal_sessions::regenerate, + self::personal_sessions::regenerate_doc, + ), + ) .api_route( "/policy-data", post_with(self::policy_data::set, self::policy_data::set_doc), @@ -94,6 +153,10 @@ where "/users/{id}/deactivate", post_with(self::users::deactivate, self::users::deactivate_doc), ) + .api_route( + "/users/{id}/reactivate", + post_with(self::users::reactivate, self::users::reactivate_doc), + ) .api_route( "/users/{id}/lock", post_with(self::users::lock, self::users::lock_doc), @@ -120,6 +183,10 @@ where "/user-sessions/{id}", get_with(self::user_sessions::get, self::user_sessions::get_doc), ) + .api_route( + "/user-sessions/{id}/finish", + post_with(self::user_sessions::finish, self::user_sessions::finish_doc), + ) .api_route( "/user-registration-tokens", get_with( @@ -178,4 +245,18 @@ where self::upstream_oauth_links::delete_doc, ), ) + .api_route( + "/upstream-oauth-providers", + get_with( + self::upstream_oauth_providers::list, + self::upstream_oauth_providers::list_doc, + ), + ) + .api_route( + "/upstream-oauth-providers/{id}", + get_with( + self::upstream_oauth_providers::get, + self::upstream_oauth_providers::get_doc, + ), + ) } diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/finish.rs b/crates/handlers/src/admin/v1/oauth2_sessions/finish.rs new file mode 100644 index 000000000..23edef30a --- /dev/null +++ b/crates/handlers/src/admin/v1/oauth2_sessions/finish.rs @@ -0,0 +1,234 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{OAuth2Session, Resource}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("OAuth 2.0 session with ID {0} not found")] + NotFound(Ulid), + + #[error("OAuth 2.0 session with ID {0} is already finished")] + AlreadyFinished(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishOAuth2Session") + .summary("Finish an OAuth 2.0 session") + .description( + "Calling this endpoint will finish the OAuth 2.0 session, preventing any further use. If the session has a user associated with it, a job will be scheduled to sync the user's devices with the homeserver.", + ) + .tag("oauth2-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, _, finished_session] = OAuth2Session::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/oauth2-sessions/{id}/finish"), + ); + t.description("OAuth 2.0 session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("OAuth 2.0 session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .oauth2_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at().is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // If the session has a user associated with it, schedule a job to sync devices + if let Some(user_id) = session.user_id { + tracing::info!(user.id = %user_id, "Scheduling device sync job for user"); + let job = SyncDevicesJob::new_for_id(user_id); + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + } + + // Finish the session + let session = repo.oauth2_session().finish(&clock, session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + OAuth2Session::from(session), + format!("/api/admin/v1/oauth2-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{AccessToken, Clock as _}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Get the session ID from the token we just created + let mut repo = state.repository().await.unwrap(); + let AccessToken { session_id, .. } = repo + .oauth2_access_token() + .find_by_token(&token) + .await + .unwrap() + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!("/api/admin/v1/oauth2-sessions/{session_id}/finish")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + + // Create first admin token for the API call + let admin_token = state.token_with_scope("urn:mas:admin").await; + + // Create a second admin session that we'll finish + let second_admin_token = state.token_with_scope("urn:mas:admin").await; + + // Get the second session and finish it first + let mut repo = state.repository().await.unwrap(); + let AccessToken { session_id, .. } = repo + .oauth2_access_token() + .find_by_token(&second_admin_token) + .await + .unwrap() + .unwrap(); + + let session = repo + .oauth2_session() + .lookup(session_id) + .await + .unwrap() + .unwrap(); + + // Finish the session first + let session = repo + .oauth2_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/oauth2-sessions/{}/finish", + session.id + )) + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!( + "OAuth 2.0 session with ID {} is already finished", + session.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "OAuth 2.0 session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/get.rs b/crates/handlers/src/admin/v1/oauth2_sessions/get.rs index 88f46ecff..653bb69b2 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/get.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/get.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs index 49b429243..37f6ed378 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -1,17 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::str::FromStr; use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -25,7 +22,7 @@ use crate::{ admin::{ call_context::CallContext, model::{OAuth2Session, Resource}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -192,16 +189,22 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p let sessions = OAuth2Session::samples(); let pagination = mas_storage::Pagination::first(sessions.len()); let page = Page { - edges: sessions.into(), + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of OAuth 2.0 sessions") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), OAuth2Session::PATH, )) }) @@ -218,10 +221,11 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p #[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = OAuth2Session::PATH); + let base = include_count.add_to_base(&base); let filter = OAuth2SessionFilter::default(); // Load the user from the filter @@ -300,15 +304,31 @@ pub async fn handler( None => filter, }; - let page = repo.oauth2_session().list(filter, pagination).await?; - let count = repo.oauth2_session().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .oauth2_session() + .list(filter, pagination) + .await? + .map(OAuth2Session::from); + let count = repo.oauth2_session().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .oauth2_session() + .list(filter, pagination) + .await? + .map(OAuth2Session::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.oauth2_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(OAuth2Session::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -354,6 +374,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MKGTBNZ16RDR3PVY" + } } } ], @@ -364,5 +389,66 @@ mod tests { } } "#); + + // Test count=false + let request = Request::get("/api/admin/v1/oauth2-sessions?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "oauth2-session", + "id": "01FSHN9AG0MKGTBNZ16RDR3PVY", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "finished_at": null, + "user_id": null, + "user_session_id": null, + "client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "scope": "urn:mas:admin", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "human_name": null + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MKGTBNZ16RDR3PVY" + } + } + } + ], + "links": { + "self": "/api/admin/v1/oauth2-sessions?count=false&page[first]=10", + "first": "/api/admin/v1/oauth2-sessions?count=false&page[first]=10", + "last": "/api/admin/v1/oauth2-sessions?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/oauth2-sessions?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions?count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs b/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs index 84a9efde5..5ac2e049e 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs @@ -1,13 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/personal_sessions/add.rs b/crates/handlers/src/admin/v1/personal_sessions/add.rs new file mode 100644 index 000000000..2cfe1fb88 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/add.rs @@ -0,0 +1,311 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::sync::Arc; + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use anyhow::Context; +use axum::{Json, extract::State, response::IntoResponse}; +use chrono::Duration; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::{BoxRng, Device, TokenType}; +use mas_matrix::HomeserverConnection; +use oauth2_types::scope::Scope; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + response::{ErrorResponse, SingleResponse}, + v1::personal_sessions::personal_session_owner_from_caller, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User not found")] + UserNotFound, + + #[error("User is not active")] + UserDeactivated, + + #[error("Invalid scope")] + InvalidScope, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound => StatusCode::NOT_FOUND, + Self::UserDeactivated => StatusCode::GONE, + Self::InvalidScope => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "CreatePersonalSessionRequest")] +pub struct Request { + /// The user this session will act on behalf of + #[schemars(with = "crate::admin::schema::Ulid")] + actor_user_id: Ulid, + + /// Human-readable name for the session + human_name: String, + + /// `OAuth2` scopes for this session + scope: String, + + /// Token expiry time in seconds. + /// If not set, the token won't expire. + expires_in: Option, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("createPersonalSession") + .summary("Create a new personal session with personal access token") + .tag("personal-session") + .response_with::<201, Json>, _>(|t| { + t.description("Personal session and personal access token were created") + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::InvalidScope); + t.description("Invalid scope provided").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)] +pub async fn handler( + CallContext { + mut repo, + clock, + session, + .. + }: CallContext, + NoApi(mut rng): NoApi, + NoApi(State(homeserver)): NoApi>>, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + let owner = personal_session_owner_from_caller(&session); + + let actor_user = repo + .user() + .lookup(params.actor_user_id) + .await? + .ok_or(RouteError::UserNotFound)?; + + if !actor_user.is_valid_actor() { + return Err(RouteError::UserDeactivated); + } + + let scope: Scope = params.scope.parse().map_err(|_| RouteError::InvalidScope)?; + + // Create the personal session + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + owner, + &actor_user, + params.human_name, + scope, + ) + .await?; + + // Create the initial token for the session + let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng); + let access_token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + &access_token_string, + params + .expires_in + .map(|exp_in| Duration::seconds(i64::from(exp_in))), + ) + .await?; + + // If the session has a device, we should add those to the homeserver now + if session.has_device() { + // Lock the user sync to make sure we don't get into a race condition + repo.user().acquire_lock_for_sync(&actor_user).await?; + + for scope in &*session.scope { + if let Some(device) = Device::from_scope_token(scope) { + // NOTE: We haven't relinquished the repo at this point, + // so we are holding a transaction across the homeserver + // operation. + // This is suboptimal, but simpler. + // Given this is an administrative endpoint, this is a tolerable + // compromise for now. + homeserver + .upsert_device(&actor_user.username, device.as_str(), None) + .await + .context("Failed to provision device") + .map_err(|e| RouteError::Internal(e.into()))?; + } + } + } + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, Some(access_token)))? + .with_token(access_token_string), + )), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use serde_json::Value; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_with_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request_body = serde_json::json!({ + "actor_user_id": user.id, + "human_name": "Test Session", + "scope": "openid urn:mas:admin", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Test Session", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-01-16T15:40:00Z", + "access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_invalid_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request_body = serde_json::json!({ + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "scope": "openid", + "human_name": "Test Session", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_invalid_scope(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request_body = serde_json::json!({ + "actor_user_id": user.id, + "human_name": "Test Session", + "scope": "invalid\nscope", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/get.rs b/crates/handlers/src/admin/v1/personal_sessions/get.rs new file mode 100644 index 000000000..c0c0378f8 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/get.rs @@ -0,0 +1,189 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Personal session not found")] + NotFound, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getPersonalSession") + .summary("Get a personal session") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PersonalSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Personal session details").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound); + t.description("Personal session not found") + .example(response) + }) +} + +#[tracing::instrument( + name = "handler.admin.v1.personal_sessions.get", + skip_all, + fields(personal_session.id = %*id), +)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let session_id = *id; + + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::NotFound)?; + + let token = if session.is_revoked() { + None + } else { + repo.personal_access_token() + .find_active_for_session(&session) + .await? + }; + + Ok(Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, token))?, + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::personal::session::PersonalSessionOwner; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add(&mut rng, &state.clock, &personal_session, "mpt_hiss", None) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/personal-sessions/{}", + personal_session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["id"], personal_session.id.to_string()); + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null, + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let session_id = Ulid::nil(); + let request = Request::get(format!("/api/admin/v1/personal-sessions/{session_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/list.rs b/crates/handlers/src/admin/v1/personal_sessions/list.rs new file mode 100644 index 000000000..c9d3d55d4 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/list.rs @@ -0,0 +1,585 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::str::FromStr as _; + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; +use axum_macros::FromRequestParts; +use chrono::{DateTime, Utc}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::personal::PersonalSessionFilter; +use oauth2_types::scope::{Scope, ScopeToken}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession, Resource}, + params::{IncludeCount, Pagination}, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Deserialize, JsonSchema, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum PersonalSessionStatus { + Active, + Revoked, +} + +impl std::fmt::Display for PersonalSessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Revoked => write!(f, "revoked"), + } + } +} + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "PersonalSessionFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Filter by owner user ID + #[serde(rename = "filter[owner_user]")] + #[schemars(with = "Option")] + owner_user: Option, + + /// Filter by owner `OAuth2` client ID + #[serde(rename = "filter[owner_client]")] + #[schemars(with = "Option")] + owner_client: Option, + + /// Filter by actor user ID + #[serde(rename = "filter[actor_user]")] + #[schemars(with = "Option")] + actor_user: Option, + + /// Retrieve the items with the given scope + #[serde(default, rename = "filter[scope]")] + scope: Vec, + + /// Filter by session status + #[serde(rename = "filter[status]")] + status: Option, + + /// Filter by access token expiry date + #[serde(rename = "filter[expires_before]")] + expires_before: Option>, + + /// Filter by access token expiry date + #[serde(rename = "filter[expires_after]")] + expires_after: Option>, + + /// Filter by whether the access token has an expiry time + #[serde(rename = "filter[expires]")] + expires: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(owner_user) = self.owner_user { + write!(f, "{sep}filter[owner_user]={owner_user}")?; + sep = '&'; + } + if let Some(owner_client) = self.owner_client { + write!(f, "{sep}filter[owner_client]={owner_client}")?; + sep = '&'; + } + if let Some(actor_user) = self.actor_user { + write!(f, "{sep}filter[actor_user]={actor_user}")?; + sep = '&'; + } + for scope in &self.scope { + write!(f, "{sep}filter[scope]={scope}")?; + sep = '&'; + } + if let Some(status) = self.status { + write!(f, "{sep}filter[status]={status}")?; + sep = '&'; + } + if let Some(expires_before) = self.expires_before { + write!( + f, + "{sep}filter[expires_before]={}", + expires_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + if let Some(expires_after) = self.expires_after { + write!( + f, + "{sep}filter[expires_after]={}", + expires_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + if let Some(expires) = self.expires { + write!(f, "{sep}filter[expires]={expires}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("Client ID {0} not found")] + ClientNotFound(Ulid), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), + + #[error("Invalid scope {0:?} in filter parameters")] + InvalidScope(String), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound(_) | Self::ClientNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidScope(_) | Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listPersonalSessions") + .summary("List personal sessions") + .description("Retrieve a list of personal sessions. +Note that by default, all sessions, including revoked ones are returned, with the oldest first. +Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let sessions = PersonalSession::samples(); + let pagination = mas_storage::Pagination::first(sessions.len()); + let page = mas_storage::Page { + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of personal sessions") + .example(PaginatedResponse::for_page( + page, + pagination, + Some(3), + PersonalSession::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::ClientNotFound(Ulid::nil())); + t.description("Client was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.list", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination, include_count): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = PersonalSession::PATH); + let base = include_count.add_to_base(&base); + + let filter = PersonalSessionFilter::new(); + + let owner_user = if let Some(owner_user_id) = params.owner_user { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or(RouteError::UserNotFound(owner_user_id))?; + Some(owner_user) + } else { + None + }; + + let filter = match &owner_user { + Some(user) => filter.for_owner_user(user), + None => filter, + }; + + let owner_client = if let Some(owner_client_id) = params.owner_client { + let owner_client = repo + .oauth2_client() + .lookup(owner_client_id) + .await? + .ok_or(RouteError::ClientNotFound(owner_client_id))?; + Some(owner_client) + } else { + None + }; + + let filter = match &owner_client { + Some(client) => filter.for_owner_oauth2_client(client), + None => filter, + }; + + let actor_user = if let Some(actor_user_id) = params.actor_user { + let user = repo + .user() + .lookup(actor_user_id) + .await? + .ok_or(RouteError::UserNotFound(actor_user_id))?; + Some(user) + } else { + None + }; + + let filter = match &actor_user { + Some(user) => filter.for_actor_user(user), + None => filter, + }; + + let scope: Scope = params + .scope + .into_iter() + .map(|s| ScopeToken::from_str(&s).map_err(|_| RouteError::InvalidScope(s))) + .collect::>()?; + + let filter = if scope.is_empty() { + filter + } else { + filter.with_scope(&scope) + }; + + let filter = match params.status { + Some(PersonalSessionStatus::Active) => filter.active_only(), + Some(PersonalSessionStatus::Revoked) => filter.finished_only(), + None => filter, + }; + + let filter = if let Some(expires_after) = params.expires_after { + filter.with_expires_after(expires_after) + } else { + filter + }; + + let filter = if let Some(expires_before) = params.expires_before { + filter.with_expires_before(expires_before) + } else { + filter + }; + + let filter = if let Some(expires) = params.expires { + filter.with_expires(expires) + } else { + filter + }; + + let response = match include_count { + IncludeCount::True => { + let page = repo.personal_session().list(filter, pagination).await?; + let count = repo.personal_session().count(filter).await?; + PaginatedResponse::for_page( + page.try_map(PersonalSession::try_from)?, + pagination, + Some(count), + &base, + ) + } + IncludeCount::False => { + let page = repo.personal_session().list(filter, pagination).await?; + PaginatedResponse::for_page( + page.try_map(PersonalSession::try_from)?, + pagination, + None, + &base, + ) + } + IncludeCount::Only => { + let count = repo.personal_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use chrono::Duration; + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::personal::session::PersonalSessionOwner; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &state.clock, + &personal_session, + "mpt_hiss", + Some(Duration::days(42)), + ) + .await + .unwrap(); + + state.clock.advance(Duration::days(1)); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Another test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &state.clock, + &personal_session, + "mpt_scratch", + Some(Duration::days(21)), + ) + .await + .unwrap(); + repo.personal_session() + .revoke(&state.clock, personal_session) + .await + .unwrap(); + + state.clock.advance(Duration::days(1)); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Another test session".to_owned(), + Scope::from_iter([OPENID, "urn:mas:admin".parse().unwrap()]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &state.clock, + &personal_session, + "mpt_meow", + Some(Duration::days(14)), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let token = state.token_with_scope("urn:mas:admin").await; + let request = Request::get("/api/admin/v1/personal-sessions") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "personal-session", + "id": "01FSHN9AG0YQYAR04VCYTHJ8SK", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG09FE39KETP6F390F8", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG09FE39KETP6F390F8", + "human_name": "Test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-02-27T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0YQYAR04VCYTHJ8SK" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0YQYAR04VCYTHJ8SK" + } + } + }, + { + "type": "personal-session", + "id": "01FSM7P1G0VBGAMK9D9QMGQ5MY", + "attributes": { + "created_at": "2022-01-17T14:40:00Z", + "revoked_at": "2022-01-17T14:40:00Z", + "owner_user_id": "01FSHN9AG09FE39KETP6F390F8", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG09FE39KETP6F390F8", + "human_name": "Another test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null, + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSM7P1G0VBGAMK9D9QMGQ5MY" + }, + "meta": { + "page": { + "cursor": "01FSM7P1G0VBGAMK9D9QMGQ5MY" + } + } + }, + { + "type": "personal-session", + "id": "01FSPT2RG08Y11Y5BM4VZ4CN8K", + "attributes": { + "created_at": "2022-01-18T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG09FE39KETP6F390F8", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG09FE39KETP6F390F8", + "human_name": "Another test session", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-02-01T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSPT2RG08Y11Y5BM4VZ4CN8K" + }, + "meta": { + "page": { + "cursor": "01FSPT2RG08Y11Y5BM4VZ4CN8K" + } + } + } + ], + "links": { + "self": "/api/admin/v1/personal-sessions?page[first]=10", + "first": "/api/admin/v1/personal-sessions?page[first]=10", + "last": "/api/admin/v1/personal-sessions?page[last]=10" + } + } + "#); + + // Map of filters to their expected set of returned ULIDs + let filters_and_expected: &[(&str, &[&str])] = &[ + ( + "filter[expires_before]=2022-02-15T00:00:00Z", + &["01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ( + "filter[expires_after]=2022-02-15T00:00:00Z", + &["01FSHN9AG0YQYAR04VCYTHJ8SK"], + ), + ( + "filter[status]=active", + &["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ("filter[status]=revoked", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]), + ( + "filter[expires]=true", + &["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ("filter[expires]=false", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]), + ( + "filter[scope]=urn:mas:admin", + &["01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ]; + + for (filter, expected_ids) in filters_and_expected { + let request = Request::get(format!("/api/admin/v1/personal-sessions?{filter}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let found: BTreeSet<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_str().unwrap()) + .collect(); + let expected: BTreeSet<&str> = expected_ids.iter().copied().collect(); + + assert_eq!( + found, expected, + "filter {filter} did not produce expected results" + ); + } + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/mod.rs b/crates/handlers/src/admin/v1/personal_sessions/mod.rs new file mode 100644 index 000000000..37c591b09 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/mod.rs @@ -0,0 +1,39 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +mod add; +mod get; +mod list; +mod regenerate; +mod revoke; + +use mas_data_model::personal::session::PersonalSessionOwner; + +pub use self::{ + add::{doc as add_doc, handler as add}, + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, + regenerate::{doc as regenerate_doc, handler as regenerate}, + revoke::{doc as revoke_doc, handler as revoke}, +}; +use crate::admin::call_context::CallerSession; + +/// Given the [`CallerSession`] of a caller of the Admin API, +/// return the [`PersonalSessionOwner`] that should own created personal +/// sessions. +fn personal_session_owner_from_caller(caller: &CallerSession) -> PersonalSessionOwner { + match caller { + CallerSession::OAuth2Session(session) => { + if let Some(user_id) = session.user_id { + PersonalSessionOwner::User(user_id) + } else { + PersonalSessionOwner::OAuth2Client(session.client_id) + } + } + CallerSession::PersonalSession(session) => { + PersonalSessionOwner::User(session.actor_user_id) + } + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/regenerate.rs b/crates/handlers/src/admin/v1/personal_sessions/regenerate.rs new file mode 100644 index 000000000..e6c70679f --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/regenerate.rs @@ -0,0 +1,246 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::Duration; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::{BoxRng, TokenType}; +use schemars::JsonSchema; +use serde::Deserialize; +use tracing::error; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + v1::personal_sessions::personal_session_owner_from_caller, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User not found")] + UserNotFound, + + #[error("Session not found")] + SessionNotFound, + + #[error("Session not valid")] + SessionNotValid, + + #[error("Session does not belong to you")] + SessionNotYours, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound | Self::SessionNotFound => StatusCode::NOT_FOUND, + Self::SessionNotValid => StatusCode::UNPROCESSABLE_ENTITY, + Self::SessionNotYours => StatusCode::FORBIDDEN, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "RegeneratePersonalSessionRequest")] +pub struct Request { + /// Token expiry time in seconds. + /// If not set, the token won't expire. + expires_in: Option, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("regeneratePersonalSession") + .summary("Regenerate a personal session by replacing its personal access token") + .tag("personal-session") + .response_with::<201, Json>, _>(|t| { + t.description( + "Personal session was regenerated and a personal access token was created", + ) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)] +pub async fn handler( + CallContext { + mut repo, + clock, + session: caller_session, + .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + let session_id = *id; + + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::SessionNotFound)?; + + if !session.is_valid() { + // We don't revive revoked sessions through regeneration + return Err(RouteError::SessionNotValid); + } + + // If the owner is not the current caller, then currently we reject the + // regeneration. + let caller = personal_session_owner_from_caller(&caller_session); + if session.owner != caller { + return Err(RouteError::SessionNotYours); + } + + // Revoke the existing active token for the session. + let old_token_opt = repo + .personal_access_token() + .find_active_for_session(&session) + .await?; + let Some(old_token) = old_token_opt else { + // This shouldn't happen + error!("session is supposedly valid but had no access token"); + return Err(RouteError::SessionNotValid); + }; + + repo.personal_access_token() + .revoke(&clock, old_token) + .await?; + + // Create the regenerated token for the session + let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng); + let access_token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + &access_token_string, + params + .expires_in + .map(|exp_in| Duration::seconds(i64::from(exp_in))), + ) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, Some(access_token)))? + .with_token(access_token_string), + )), + )) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use serde_json::{Value, json}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_regenerate_personal_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(json!({ + "actor_user_id": user.id, + "human_name": "SuperDuperAdminCLITool Token", + "scope": "openid urn:mas:admin", + "expires_in": 3600 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let created: Value = response.json(); + + let session_id = created["data"]["id"].as_str().unwrap(); + + state.clock.advance(Duration::minutes(3)); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{session_id}/regenerate" + )) + .bearer(&token) + .json(json!({ + "expires_in": 86400 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "SuperDuperAdminCLITool Token", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-01-17T14:43:00Z", + "access_token": "mpt_6cq7FqNSYoosbXl3bbpfh9yNy9NzuR_0vOV2O" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + "#); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/revoke.rs b/crates/handlers/src/admin/v1/personal_sessions/revoke.rs new file mode 100644 index 000000000..10fd6650f --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/revoke.rs @@ -0,0 +1,250 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Personal session with ID {0} not found")] + NotFound(Ulid), + + #[error("Personal session with ID {0} is already revoked")] + AlreadyRevoked(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyRevoked(_) => StatusCode::CONFLICT, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("revokePersonalSession") + .summary("Revoke a personal session") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PersonalSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Personal session was revoked") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Personal session not found") + .example(response) + }) + .response_with::<409, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyRevoked(Ulid::nil())); + t.description("Personal session already revoked") + .example(response) + }) +} + +#[tracing::instrument( + name = "handler.admin.v1.personal_sessions.revoke", + skip_all, + fields(personal_session.id = %*session_id), +)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + session_id: UlidPathParam, +) -> Result>, RouteError> { + let session_id = *session_id; + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::NotFound(session_id))?; + + if session.is_revoked() { + return Err(RouteError::AlreadyRevoked(session_id)); + } + + let session = repo.personal_session().revoke(&clock, session).await?; + + if session.has_device() { + // If the session has a device, then we are now + // deleting a device and should schedule a device sync to clean up. + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SyncDevicesJob::new_for_id(session.actor_user_id), + ) + .await?; + } + + repo.save().await?; + + Ok(Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, None))?, + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock, personal::session::PersonalSessionOwner}; + use oauth2_types::scope::Scope; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([]), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{}/revoke", + personal_session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The revoked_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["revoked_at"], + serde_json::json!(Clock::now(&state.clock)) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_already_revoked_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([]), + ) + .await + .unwrap(); + + // Revoke the session first + let session = repo + .personal_session() + .revoke(&state.clock, personal_session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{}/revoke", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!("Personal session with ID {} is already revoked", session.id) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/personal-sessions/01040G2081040G2081040G2081/revoke") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Personal session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/policy_data/get.rs b/crates/handlers/src/admin/v1/policy_data/get.rs index 51d8c7849..1ba0517fc 100644 --- a/crates/handlers/src/admin/v1/policy_data/get.rs +++ b/crates/handlers/src/admin/v1/policy_data/get.rs @@ -1,6 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/policy_data/get_latest.rs b/crates/handlers/src/admin/v1/policy_data/get_latest.rs index f217b30dc..102c578f1 100644 --- a/crates/handlers/src/admin/v1/policy_data/get_latest.rs +++ b/crates/handlers/src/admin/v1/policy_data/get_latest.rs @@ -1,6 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/policy_data/mod.rs b/crates/handlers/src/admin/v1/policy_data/mod.rs index 9143a2e11..f8952e711 100644 --- a/crates/handlers/src/admin/v1/policy_data/mod.rs +++ b/crates/handlers/src/admin/v1/policy_data/mod.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod get; mod get_latest; diff --git a/crates/handlers/src/admin/v1/policy_data/set.rs b/crates/handlers/src/admin/v1/policy_data/set.rs index 73dce7307..5bee61415 100644 --- a/crates/handlers/src/admin/v1/policy_data/set.rs +++ b/crates/handlers/src/admin/v1/policy_data/set.rs @@ -1,6 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; @@ -8,8 +9,8 @@ use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_data_model::BoxRng; use mas_policy::PolicyFactory; -use mas_storage::BoxRng; use schemars::JsonSchema; use serde::Deserialize; diff --git a/crates/handlers/src/admin/v1/site_config.rs b/crates/handlers/src/admin/v1/site_config.rs new file mode 100644 index 000000000..40a5db51a --- /dev/null +++ b/crates/handlers/src/admin/v1/site_config.rs @@ -0,0 +1,97 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::transform::TransformOperation; +use axum::{Json, extract::State}; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::admin::call_context::CallContext; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Serialize, JsonSchema)] +pub struct SiteConfig { + /// The Matrix server name for which this instance is configured + server_name: String, + + /// Whether password login is enabled. + pub password_login_enabled: bool, + + /// Whether password registration is enabled. + pub password_registration_enabled: bool, + + /// Whether a valid email address is required for password registrations. + pub password_registration_email_required: bool, + + /// Whether registration tokens are required for password registrations. + pub registration_token_required: bool, + + /// Whether users can change their email. + pub email_change_allowed: bool, + + /// Whether users can change their display name. + pub displayname_change_allowed: bool, + + /// Whether users can change their password. + pub password_change_allowed: bool, + + /// Whether users can recover their account via email. + pub account_recovery_allowed: bool, + + /// Whether users can delete their own account. + pub account_deactivation_allowed: bool, + + /// Whether CAPTCHA during registration is enabled. + pub captcha_enabled: bool, + + /// Minimum password complexity, between 0 and 4. + /// This is a score from zxcvbn. + #[schemars(range(min = 0, max = 4))] + pub minimum_password_complexity: u8, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("siteConfig") + .tag("server") + .summary("Get informations about the configuration of this MAS instance") + .response_with::<200, Json, _>(|t| { + t.example(SiteConfig { + server_name: "example.com".to_owned(), + password_login_enabled: true, + password_registration_enabled: true, + password_registration_email_required: true, + registration_token_required: true, + email_change_allowed: true, + displayname_change_allowed: true, + password_change_allowed: true, + account_recovery_allowed: true, + account_deactivation_allowed: true, + captcha_enabled: true, + minimum_password_complexity: 3, + }) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.site_config", skip_all)] +pub async fn handler( + _: CallContext, + State(site_config): State, +) -> Json { + Json(SiteConfig { + server_name: site_config.server_name, + password_login_enabled: site_config.password_login_enabled, + password_registration_enabled: site_config.password_registration_enabled, + password_registration_email_required: site_config.password_registration_email_required, + registration_token_required: site_config.registration_token_required, + email_change_allowed: site_config.email_change_allowed, + displayname_change_allowed: site_config.displayname_change_allowed, + password_change_allowed: site_config.password_change_allowed, + account_recovery_allowed: site_config.account_recovery_allowed, + account_deactivation_allowed: site_config.account_deactivation_allowed, + captcha_enabled: site_config.captcha.is_some(), + minimum_password_complexity: site_config.minimum_password_complexity, + }) +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs index 3cdbc783f..568216249 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs @@ -1,13 +1,13 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_storage::BoxRng; +use mas_data_model::BoxRng; use schemars::JsonSchema; use serde::Deserialize; use ulid::Ulid; diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs index 403c1bc33..3e87109b5 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; @@ -126,7 +126,7 @@ mod tests { let session = repo .upstream_oauth_session() - .complete_with_link(&state.clock, session, &link, None, None, None) + .complete_with_link(&state.clock, session, &link, None, None, None, None) .await .unwrap(); diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs index 4fd5158d3..483d90891 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs index e46b85820..c233a9977 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs @@ -1,14 +1,11 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UpstreamOAuthLink}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -112,16 +109,22 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let links = UpstreamOAuthLink::samples(); let pagination = mas_storage::Pagination::first(links.len()); let page = Page { - edges: links.into(), + edges: links + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of upstream OAuth 2.0 links") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UpstreamOAuthLink::PATH, )) }) @@ -135,10 +138,11 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UpstreamOAuthLink::PATH); + let base = include_count.add_to_base(&base); let filter = UpstreamOAuthLinkFilter::default(); // Load the user from the filter @@ -183,15 +187,31 @@ pub async fn handler( filter }; - let page = repo.upstream_oauth_link().list(filter, pagination).await?; - let count = repo.upstream_oauth_link().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .upstream_oauth_link() + .list(filter, pagination) + .await? + .map(UpstreamOAuthLink::from); + let count = repo.upstream_oauth_link().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .upstream_oauth_link() + .list(filter, pagination) + .await? + .map(UpstreamOAuthLink::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.upstream_oauth_link().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(UpstreamOAuthLink::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -296,7 +316,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 3 @@ -314,6 +334,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } }, { @@ -328,6 +353,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4" + } } }, { @@ -342,6 +372,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } } } ], @@ -351,7 +386,7 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?page[last]=10" } } - "###); + "#); // Filter by user ID let request = Request::get(format!( @@ -364,7 +399,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -382,6 +417,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } }, { @@ -396,6 +436,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } } } ], @@ -405,7 +450,7 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by provider let request = Request::get(format!( @@ -418,7 +463,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -436,6 +481,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } }, { @@ -450,6 +500,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4" + } } } ], @@ -459,7 +514,7 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[last]=10" } } - "###); + "#); // Filter by subject let request = Request::get(format!( @@ -472,7 +527,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -490,6 +545,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } } ], @@ -499,6 +559,181 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[last]=10" } } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/upstream-oauth-links?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject3", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "human_account_name": "bob@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4" + } + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "subject": "subject2", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@example" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/upstream-oauth-links?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 3 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links?count=only" + } + } "###); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "subject": "subject2", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@example" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?count=only&filter[provider]={}", + provider1.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs index 6696a7109..3433aa3ca 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod add; mod delete; @@ -19,7 +19,8 @@ pub use self::{ mod test_utils { use mas_data_model::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_storage::upstream_oauth2::UpstreamOAuthProviderParams; @@ -49,6 +50,7 @@ mod test_utils { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, } } } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/get.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/get.rs new file mode 100644 index 000000000..3700e1a65 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/get.rs @@ -0,0 +1,196 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::{RepositoryAccess, upstream_oauth2::UpstreamOAuthProviderRepository}; + +use crate::{ + admin::{ + call_context::CallContext, + model::UpstreamOAuthProvider, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Provider not found")] + NotFound, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound => StatusCode::NOT_FOUND, + }; + + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getUpstreamOAuthProvider") + .summary("Get upstream OAuth provider") + .tag("upstream-oauth-provider") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = UpstreamOAuthProvider::samples(); + t.description("The upstream OAuth provider") + .example(SingleResponse::new_canonical(sample)) + }) + .response_with::<404, Json, _>(|t| t.description("Provider not found")) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_providers.get", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let provider = repo + .upstream_oauth_provider() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound)?; + + Ok(Json(SingleResponse::new_canonical( + UpstreamOAuthProvider::from(provider), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_data_model::{ + UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, + }; + use mas_iana::jose::JsonWebSignatureAlg; + use mas_storage::{ + RepositoryAccess, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository}, + }; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + async fn create_test_provider(state: &mut TestState) -> UpstreamOAuthProvider { + let mut repo = state.repository().await.unwrap(); + + let params = UpstreamOAuthProviderParams { + issuer: Some("https://accounts.google.com".to_owned()), + human_name: Some("Google".to_owned()), + brand_name: Some("google".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "google-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 0, + }; + + let provider = repo + .upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, params) + .await + .unwrap(); + + Box::new(repo).save().await.unwrap(); + + provider + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_provider(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + let provider = create_test_provider(&mut state).await; + + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-providers/{}", + provider.id + )) + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + assert_eq!(body["data"]["type"], "upstream-oauth-provider"); + assert_eq!(body["data"]["id"], provider.id.to_string()); + assert_eq!(body["data"]["attributes"]["human_name"], "Google"); + + insta::assert_json_snapshot!(body, @r###" + { + "data": { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + + let provider_id = Ulid::nil(); + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-providers/{provider_id}" + )) + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs new file mode 100644 index 000000000..d70bbd299 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs @@ -0,0 +1,799 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::{Page, upstream_oauth2::UpstreamOAuthProviderFilter}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UpstreamOAuthProvider}, + params::{IncludeCount, Pagination}, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "UpstreamOAuthProviderFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve providers that are (or are not) enabled + #[serde(rename = "filter[enabled]")] + enabled: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(enabled) = self.enabled { + write!(f, "{sep}filter[enabled]={enabled}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listUpstreamOAuthProviders") + .summary("List upstream OAuth 2.0 providers") + .tag("upstream-oauth-provider") + .response_with::<200, Json>, _>(|t| { + let providers = UpstreamOAuthProvider::samples(); + let pagination = mas_storage::Pagination::first(providers.len()); + let page = Page { + edges: providers + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of upstream OAuth 2.0 providers") + .example(PaginatedResponse::for_page( + page, + pagination, + Some(42), + UpstreamOAuthProvider::PATH, + )) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_providers.list", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination, include_count): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UpstreamOAuthProvider::PATH); + let base = include_count.add_to_base(&base); + let filter = UpstreamOAuthProviderFilter::new(); + + let filter = match params.enabled { + Some(true) => filter.enabled_only(), + Some(false) => filter.disabled_only(), + None => filter, + }; + + let response = match include_count { + IncludeCount::True => { + let page = repo + .upstream_oauth_provider() + .list(filter, pagination) + .await? + .map(UpstreamOAuthProvider::from); + let count = repo.upstream_oauth_provider().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .upstream_oauth_provider() + .list(filter, pagination) + .await? + .map(UpstreamOAuthProvider::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.upstream_oauth_provider().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderTokenAuthMethod, + }; + use mas_iana::jose::JsonWebSignatureAlg; + use mas_storage::{ + RepositoryAccess, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository}, + }; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + async fn create_test_providers(state: &mut TestState) { + let mut repo = state.repository().await.unwrap(); + + // Create an enabled provider + let enabled_params = UpstreamOAuthProviderParams { + issuer: Some("https://accounts.google.com".to_owned()), + human_name: Some("Google".to_owned()), + brand_name: Some("google".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "google-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 0, + }; + + repo.upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, enabled_params) + .await + .unwrap(); + + // Create a disabled provider + let disabled_params = UpstreamOAuthProviderParams { + issuer: Some("https://appleid.apple.com".to_owned()), + human_name: Some("Apple ID".to_owned()), + brand_name: Some("apple".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::S256, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "apple-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 1, + }; + + let disabled_provider = repo + .upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, disabled_params) + .await + .unwrap(); + + // Disable the provider + repo.upstream_oauth_provider() + .disable(&state.clock, disabled_provider) + .await + .unwrap(); + + // Create another enabled provider + let another_enabled_params = UpstreamOAuthProviderParams { + issuer: Some("https://login.microsoftonline.com/common/v2.0".to_owned()), + human_name: Some("Microsoft".to_owned()), + brand_name: Some("microsoft".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "microsoft-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 2, + }; + + repo.upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, another_enabled_params) + .await + .unwrap(); + + Box::new(repo).save().await.unwrap(); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_all_providers(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + let request = Request::get("/api/admin/v1/upstream-oauth-providers") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + // Should return all providers + assert_eq!(body["data"].as_array().unwrap().len(), 3); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_enabled_true(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + let request = Request::get("/api/admin/v1/upstream-oauth-providers?filter[enabled]=true") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_enabled_false(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + let request = Request::get("/api/admin/v1/upstream-oauth-providers?filter[enabled]=false") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_pagination(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + // Test first page with limit of 2 + let request = Request::get("/api/admin/v1/upstream-oauth-providers?page[first]=2") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[first]=2", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=2", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=2", + "next": "/api/admin/v1/upstream-oauth-providers?page[after]=01FSHN9AG09AVTNSQFMSR34AJC&page[first]=2" + } + } + "#); + + // Extract the ID of the last item for pagination + let last_item_id = body["data"][1]["id"].as_str().unwrap(); + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-providers?page[first]=2&page[after]={last_item_id}", + )) + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[after]=01FSHN9AG09AVTNSQFMSR34AJC&page[first]=2", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=2", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=2" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invalid_filter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::get("/api/admin/v1/upstream-oauth-providers?filter[enabled]=invalid") + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_count_parameter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + // Test count=false + let request = Request::get("/api/admin/v1/upstream-oauth-providers?count=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/upstream-oauth-providers?count=only") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?count=only" + } + } + "#); + + // Test count=false with filtering + let request = + Request::get("/api/admin/v1/upstream-oauth-providers?count=false&filter[enabled]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = + Request::get("/api/admin/v1/upstream-oauth-providers?count=only&filter[enabled]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&count=only" + } + } + "#); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs new file mode 100644 index 000000000..18ffe5af6 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs @@ -0,0 +1,12 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +mod get; +mod list; + +pub use self::{ + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, +}; diff --git a/crates/handlers/src/admin/v1/user_emails/add.rs b/crates/handlers/src/admin/v1/user_emails/add.rs index f3a39e20a..10622b619 100644 --- a/crates/handlers/src/admin/v1/user_emails/add.rs +++ b/crates/handlers/src/admin/v1/user_emails/add.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::str::FromStr as _; @@ -9,8 +9,8 @@ use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_data_model::BoxRng; use mas_storage::{ - BoxRng, queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, user::UserEmailFilter, }; diff --git a/crates/handlers/src/admin/v1/user_emails/delete.rs b/crates/handlers/src/admin/v1/user_emails/delete.rs index ad7df7acb..133e85992 100644 --- a/crates/handlers/src/admin/v1/user_emails/delete.rs +++ b/crates/handlers/src/admin/v1/user_emails/delete.rs @@ -1,16 +1,14 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_storage::{ - BoxRng, - queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, -}; +use mas_data_model::BoxRng; +use mas_storage::queue::{ProvisionUserJob, QueueJobRepositoryExt as _}; use ulid::Ulid; use crate::{ diff --git a/crates/handlers/src/admin/v1/user_emails/get.rs b/crates/handlers/src/admin/v1/user_emails/get.rs index 9232b0663..826cb8c25 100644 --- a/crates/handlers/src/admin/v1/user_emails/get.rs +++ b/crates/handlers/src/admin/v1/user_emails/get.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/user_emails/list.rs b/crates/handlers/src/admin/v1/user_emails/list.rs index f7adb23da..453ef0e89 100644 --- a/crates/handlers/src/admin/v1/user_emails/list.rs +++ b/crates/handlers/src/admin/v1/user_emails/list.rs @@ -1,14 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UserEmail}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -99,16 +96,22 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let emails = UserEmail::samples(); let pagination = mas_storage::Pagination::first(emails.len()); let page = Page { - edges: emails.into(), + edges: emails + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of user emails") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UserEmail::PATH, )) }) @@ -121,10 +124,11 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UserEmail::PATH); + let base = include_count.add_to_base(&base); let filter = UserEmailFilter::default(); // Load the user from the filter @@ -150,15 +154,31 @@ pub async fn handler( None => filter, }; - let page = repo.user_email().list(filter, pagination).await?; - let count = repo.user_email().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .user_email() + .list(filter, pagination) + .await? + .map(UserEmail::from); + let count = repo.user_email().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .user_email() + .list(filter, pagination) + .await? + .map(UserEmail::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.user_email().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(UserEmail::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -209,7 +229,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -225,6 +245,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } } }, { @@ -237,6 +262,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z" + } } } ], @@ -246,7 +276,7 @@ mod tests { "last": "/api/admin/v1/user-emails?page[last]=10" } } - "###); + "#); // Filter by user let request = Request::get(format!( @@ -258,7 +288,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -274,6 +304,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } } } ], @@ -283,7 +318,7 @@ mod tests { "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by email let request = Request::get("/api/admin/v1/user-emails?filter[email]=alice@example.com") @@ -292,7 +327,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -308,6 +343,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } } } ], @@ -317,6 +357,137 @@ mod tests { "last": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[last]=10" } } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/user-emails?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } + } + }, + { + "type": "user-email", + "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "email": "bob@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?count=false&page[first]=10", + "first": "/api/admin/v1/user-emails?count=false&page[first]=10", + "last": "/api/admin/v1/user-emails?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/user-emails?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/user-emails?count=only" + } + } "###); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/user-emails?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get(format!( + "/api/admin/v1/user-emails?count=only&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/user_emails/mod.rs b/crates/handlers/src/admin/v1/user_emails/mod.rs index 136c132ba..38f3ec989 100644 --- a/crates/handlers/src/admin/v1/user_emails/mod.rs +++ b/crates/handlers/src/admin/v1/user_emails/mod.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod add; mod delete; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs index afa409a67..3e641e25d 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs @@ -1,14 +1,15 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use chrono::{DateTime, Utc}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_storage::BoxRng; +use mas_data_model::BoxRng; use rand::distributions::{Alphanumeric, DistString}; use schemars::JsonSchema; use serde::Deserialize; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs index 833e3b17c..187c19032 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs @@ -1,7 +1,8 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs index 85a1a1945..26e925401 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs @@ -1,14 +1,12 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -20,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UserRegistrationToken}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -111,16 +109,22 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let tokens = UserRegistrationToken::samples(); let pagination = mas_storage::Pagination::first(tokens.len()); let page = Page { - edges: tokens.into(), + edges: tokens + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of registration tokens") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UserRegistrationToken::PATH, )) }) @@ -131,10 +135,11 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UserRegistrationToken::PATH); + let base = include_count.add_to_base(&base); let now = clock.now(); let mut filter = UserRegistrationTokenFilter::new(now); @@ -154,25 +159,38 @@ pub async fn handler( filter = filter.with_valid(valid); } - let page = repo - .user_registration_token() - .list(filter, pagination) - .await?; - let count = repo.user_registration_token().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .user_registration_token() + .list(filter, pagination) + .await? + .map(|token| UserRegistrationToken::new(token, now)); + let count = repo.user_registration_token().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .user_registration_token() + .list(filter, pagination) + .await? + .map(|token| UserRegistrationToken::new(token, now)); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.user_registration_token().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(|token| UserRegistrationToken::new(token, now)), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_storage::Clock as _; + use mas_data_model::Clock as _; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -299,6 +317,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -316,6 +339,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -333,6 +361,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -350,6 +383,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } }, { @@ -367,6 +405,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -415,6 +458,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -432,6 +480,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -472,6 +525,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -489,6 +547,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -506,6 +569,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -554,6 +622,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -571,6 +644,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -611,6 +689,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -628,6 +711,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -645,6 +733,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -693,6 +786,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } } ], @@ -733,6 +831,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -750,6 +853,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -767,6 +875,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } }, { @@ -784,6 +897,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -832,6 +950,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -849,6 +972,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -889,6 +1017,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -906,6 +1039,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -923,6 +1061,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -973,6 +1116,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -1021,6 +1169,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -1038,6 +1191,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } } ], @@ -1079,6 +1237,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -1096,6 +1259,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -1137,6 +1305,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -1171,4 +1344,242 @@ mod tests { .contains("Invalid filter parameters") ); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_count_parameter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Test count=false + let request = Request::get("/api/admin/v1/user-registration-tokens?count=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?count=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?count=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/user-registration-tokens?count=only") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens?count=only" + } + } + "#); + + // Test count=false with filtering + let request = + Request::get("/api/admin/v1/user-registration-tokens?count=false&filter[valid]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[valid]=true&count=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[valid]=true&count=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[valid]=true&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = + Request::get("/api/admin/v1/user-registration-tokens?count=only&filter[revoked]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&count=only" + } + } + "#); + } } diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs index 3d61e10e6..42d16af7b 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -1,7 +1,8 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod add; mod get; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs index e649cfef8..b2632d4b6 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs @@ -1,7 +1,8 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; @@ -104,7 +105,7 @@ pub async fn handler( mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_storage::Clock as _; + use mas_data_model::Clock as _; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs index 53cbfcf95..212b7cdf9 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs @@ -1,7 +1,8 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs index 444c7ae6b..06ad8283e 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs @@ -1,7 +1,8 @@ +// Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; @@ -142,7 +143,7 @@ pub async fn handler( mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_storage::Clock as _; + use mas_data_model::Clock as _; use serde_json::json; use sqlx::PgPool; diff --git a/crates/handlers/src/admin/v1/user_sessions/finish.rs b/crates/handlers/src/admin/v1/user_sessions/finish.rs new file mode 100644 index 000000000..a50253f11 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_sessions/finish.rs @@ -0,0 +1,216 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User session with ID {0} not found")] + NotFound(Ulid), + + #[error("User session with ID {0} is already finished")] + AlreadyFinished(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishUserSession") + .summary("Finish a user session") + .description( + "Calling this endpoint will finish the user session, preventing any further use.", + ) + .tag("user-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, _, finished_session] = UserSession::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/user-sessions/{id}/finish"), + ); + t.description("User session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .browser_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at.is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // Finish the session + let session = repo.browser_session().finish(&clock, session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + UserSession::from(session), + format!("/api/admin/v1/user-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::Clock as _; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a user session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!("/api/admin/v1/user-sessions/{}/finish", session.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a user session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + + // Finish the session first + let session = repo + .browser_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!("/api/admin/v1/user-sessions/{}/finish", session.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!("User session with ID {} is already finished", session.id) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/user-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "User session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/user_sessions/get.rs b/crates/handlers/src/admin/v1/user_sessions/get.rs index a59b10d0e..0a65c80c1 100644 --- a/crates/handlers/src/admin/v1/user_sessions/get.rs +++ b/crates/handlers/src/admin/v1/user_sessions/get.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/user_sessions/list.rs b/crates/handlers/src/admin/v1/user_sessions/list.rs index a04bf057f..ad8a05982 100644 --- a/crates/handlers/src/admin/v1/user_sessions/list.rs +++ b/crates/handlers/src/admin/v1/user_sessions/list.rs @@ -1,14 +1,11 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UserSession}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -123,16 +120,22 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p let sessions = UserSession::samples(); let pagination = mas_storage::Pagination::first(sessions.len()); let page = Page { - edges: sessions.into(), + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of user sessions") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UserSession::PATH, )) }) @@ -145,10 +148,11 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p #[tracing::instrument(name = "handler.admin.v1.user_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UserSession::PATH); + let base = include_count.add_to_base(&base); let filter = BrowserSessionFilter::default(); // Load the user from the filter @@ -175,15 +179,31 @@ pub async fn handler( None => filter, }; - let page = repo.browser_session().list(filter, pagination).await?; - let count = repo.browser_session().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .browser_session() + .list(filter, pagination) + .await? + .map(UserSession::from); + let count = repo.browser_session().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .browser_session() + .list(filter, pagination) + .await? + .map(UserSession::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.browser_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(UserSession::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -241,7 +261,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -260,6 +280,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } }, { @@ -275,6 +300,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AJ6AC5HQ9X6H4RP4" + } } } ], @@ -284,7 +314,7 @@ mod tests { "last": "/api/admin/v1/user-sessions?page[last]=10" } } - "###); + "#); // Filter by user let request = Request::get(format!( @@ -296,7 +326,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -315,6 +345,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -324,7 +359,7 @@ mod tests { "last": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by status (active) let request = Request::get("/api/admin/v1/user-sessions?filter[status]=active") @@ -333,7 +368,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -352,6 +387,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -361,7 +401,7 @@ mod tests { "last": "/api/admin/v1/user-sessions?filter[status]=active&page[last]=10" } } - "###); + "#); // Filter by status (finished) let request = Request::get("/api/admin/v1/user-sessions?filter[status]=finished") @@ -370,7 +410,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -389,6 +429,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AJ6AC5HQ9X6H4RP4" + } } } ], @@ -398,6 +443,143 @@ mod tests { "last": "/api/admin/v1/user-sessions?filter[status]=finished&page[last]=10" } } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/user-sessions?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-session", + "id": "01FSHNB5309NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:41:00Z", + "finished_at": null, + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + }, + { + "type": "user-session", + "id": "01FSHNB530KEPHYQQXW9XPTX6Z", + "attributes": { + "created_at": "2022-01-16T14:41:00Z", + "finished_at": "2022-01-16T14:42:00Z", + "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AJ6AC5HQ9X6H4RP4" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-sessions?count=false&page[first]=10", + "first": "/api/admin/v1/user-sessions?count=false&page[first]=10", + "last": "/api/admin/v1/user-sessions?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/user-sessions?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/user-sessions?count=only" + } + } "###); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/user-sessions?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-session", + "id": "01FSHNB5309NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:41:00Z", + "finished_at": null, + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get("/api/admin/v1/user-sessions?count=only&filter[status]=active") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/user-sessions?filter[status]=active&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/user_sessions/mod.rs b/crates/handlers/src/admin/v1/user_sessions/mod.rs index 23c05c416..db7b17ff5 100644 --- a/crates/handlers/src/admin/v1/user_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/user_sessions/mod.rs @@ -1,12 +1,14 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/users/add.rs b/crates/handlers/src/admin/v1/users/add.rs index 9867b06ec..07e87fb40 100644 --- a/crates/handlers/src/admin/v1/users/add.rs +++ b/crates/handlers/src/admin/v1/users/add.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; @@ -10,11 +10,8 @@ use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_matrix::HomeserverConnection; -use mas_storage::{ - BoxRng, - queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, -}; +use mas_data_model::BoxRng; +use mas_matrix::{HomeserverConnection, ProvisionRequest}; use schemars::JsonSchema; use serde::Deserialize; use tracing::warn; @@ -168,9 +165,10 @@ pub async fn handler( let user = repo.user().add(&mut rng, &clock, params.username).await?; - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) - .await?; + homeserver + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .await + .map_err(RouteError::Homeserver)?; repo.save().await?; @@ -183,6 +181,7 @@ pub async fn handler( #[cfg(test)] mod tests { use hyper::{Request, StatusCode}; + use mas_matrix::HomeserverConnection; use mas_storage::{RepositoryAccess, user::UserRepository}; use sqlx::PgPool; @@ -218,6 +217,10 @@ mod tests { .unwrap(); assert_eq!(user.username, "alice"); + + // Check that the user was created on the homeserver + let result = state.homeserver_connection.query_user("alice").await; + assert!(result.is_ok()); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/users/by_username.rs b/crates/handlers/src/admin/v1/users/by_username.rs index 98ddb8b3c..2ba122039 100644 --- a/crates/handlers/src/admin/v1/users/by_username.rs +++ b/crates/handlers/src/admin/v1/users/by_username.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, extract::Path, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/users/deactivate.rs b/crates/handlers/src/admin/v1/users/deactivate.rs index fad2f5257..b963b73d5 100644 --- a/crates/handlers/src/admin/v1/users/deactivate.rs +++ b/crates/handlers/src/admin/v1/users/deactivate.rs @@ -1,17 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_storage::{ - BoxRng, - queue::{DeactivateUserJob, QueueJobRepositoryExt as _}, -}; +use mas_data_model::BoxRng; +use mas_storage::queue::{DeactivateUserJob, QueueJobRepositoryExt as _}; +use schemars::JsonSchema; +use serde::Deserialize; use tracing::info; use ulid::Ulid; @@ -49,18 +49,40 @@ impl IntoResponse for RouteError { } } -pub fn doc(operation: TransformOperation) -> TransformOperation { +/// # JSON payload for the `POST /api/admin/v1/users/:id/deactivate` endpoint +#[derive(Default, Deserialize, JsonSchema)] +#[serde(rename = "DeactivateUserRequest")] +pub struct Request { + /// Whether to skip requesting the homeserver to GDPR-erase the user upon + /// deactivation. + #[serde(default)] + skip_erase: bool, +} + +pub fn doc(mut operation: TransformOperation) -> TransformOperation { + operation + .inner_mut() + .request_body + .as_mut() + .unwrap() + .as_item_mut() + .unwrap() + .required = false; + operation .id("deactivateUser") .summary("Deactivate a user") - .description("Calling this endpoint will lock and deactivate the user, preventing them from doing any action. -This invalidates any existing session, and will ask the homeserver to make them leave all rooms.") + .description( + "Calling this endpoint will deactivate the user, preventing them from doing any action. +This invalidates any existing session, and will ask the homeserver to make them leave all rooms.", + ) .tag("user") .response_with::<200, Json>, _>(|t| { // In the samples, the third user is the one locked let [_alice, _bob, charlie, ..] = User::samples(); let id = charlie.id(); - let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate")); + let response = + SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate")); t.description("User was deactivated").example(response) }) .response_with::<404, RouteError, _>(|t| { @@ -76,21 +98,25 @@ pub async fn handler( }: CallContext, NoApi(mut rng): NoApi, id: UlidPathParam, + body: Option>, ) -> Result>, RouteError> { + let Json(params) = body.unwrap_or_default(); let id = *id; - let mut user = repo + let user = repo .user() .lookup(id) .await? .ok_or(RouteError::NotFound(id))?; - if user.locked_at.is_none() { - user = repo.user().lock(&clock, user).await?; - } + let user = repo.user().deactivate(&clock, user).await?; info!(%user.id, "Scheduling deactivation of user"); repo.queue_job() - .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, true)) + .schedule_job( + &mut rng, + &clock, + DeactivateUserJob::new(&user, !params.skip_erase), + ) .await?; repo.save().await?; @@ -105,13 +131,14 @@ pub async fn handler( mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_storage::{Clock, RepositoryAccess, user::UserRepository}; + use insta::{allow_duplicates, assert_json_snapshot}; + use mas_data_model::Clock; + use mas_storage::{RepositoryAccess, user::UserRepository}; use sqlx::{PgPool, types::Json}; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_deactivate_user(pool: PgPool) { + async fn test_deactivate_user_helper(pool: PgPool, skip_erase: Option) { setup(); let mut state = TestState::from_pool(pool.clone()).await.unwrap(); let token = state.token_with_scope("urn:mas:admin").await; @@ -124,17 +151,28 @@ mod tests { .unwrap(); repo.save().await.unwrap(); - let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id)) - .bearer(&token) - .empty(); + let request = + Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id)).bearer(&token); + let request = match skip_erase { + None => request.empty(), + Some(skip_erase) => request.json(serde_json::json!({ + "skip_erase": skip_erase, + })), + }; let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - // The locked_at timestamp should be the same as the current time + // The deactivated_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::json!(state.clock.now()) + ); + + // Deactivating the user should not lock it assert_eq!( body["data"]["attributes"]["locked_at"], - serde_json::json!(state.clock.now()) + serde_json::Value::Null ); // It should have scheduled a deactivation job for the user @@ -146,6 +184,53 @@ mod tests { .await .expect("Deactivation job to be scheduled"); assert_eq!(job["user_id"], serde_json::json!(user.id)); + assert_eq!( + job["hs_erase"], + serde_json::json!(!skip_erase.unwrap_or(false)) + ); + + // Make sure to run the jobs in the queue + state.run_jobs_in_queue().await; + + let request = Request::get(format!("/api/admin/v1/users/{}", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + allow_duplicates!(assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": "2022-01-16T14:40:00Z", + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#)); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_deactivate_user(pool: PgPool) { + test_deactivate_user_helper(pool, Option::None).await; + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_deactivate_user_skip_erase(pool: PgPool) { + test_deactivate_user_helper(pool, Option::Some(true)).await; } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] @@ -173,21 +258,50 @@ mod tests { response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - // The locked_at timestamp should be different from the current time - assert_ne!( - body["data"]["attributes"]["locked_at"], + // The deactivated_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["deactivated_at"], serde_json::json!(state.clock.now()) ); - // It should have scheduled a deactivation job for the user - // XXX: we don't have a good way to look for the deactivation job - let job: Json = sqlx::query_scalar( - "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'", - ) - .fetch_one(&pool) - .await - .expect("Deactivation job to be scheduled"); - assert_eq!(job["user_id"], serde_json::json!(user.id)); + // The deactivated_at timestamp should be different from the locked_at timestamp + assert_ne!( + body["data"]["attributes"]["deactivated_at"], + body["data"]["attributes"]["locked_at"], + ); + + // Make sure to run the jobs in the queue + state.run_jobs_in_queue().await; + + let request = Request::get(format!("/api/admin/v1/users/{}", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": "2022-01-16T14:40:00Z", + "deactivated_at": "2022-01-16T14:41:00Z", + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/users/get.rs b/crates/handlers/src/admin/v1/users/get.rs index 59d221fbb..afc177011 100644 --- a/crates/handlers/src/admin/v1/users/get.rs +++ b/crates/handlers/src/admin/v1/users/get.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs index 9bc04ab4a..65375402e 100644 --- a/crates/handlers/src/admin/v1/users/list.rs +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -1,15 +1,12 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, User}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -54,6 +51,17 @@ pub struct FilterParams { #[serde(rename = "filter[admin]")] admin: Option, + /// Retrieve users with (or without) the `legacy_guest` flag set + #[serde(rename = "filter[legacy-guest]")] + legacy_guest: Option, + + /// Retrieve users where the username matches contains the given string + /// + /// Note that this doesn't change the ordering of the result, which are + /// still ordered by ID. + #[serde(rename = "filter[search]")] + search: Option, + /// Retrieve the items with the given status /// /// Defaults to retrieve all users, including locked ones. @@ -75,6 +83,14 @@ impl std::fmt::Display for FilterParams { write!(f, "{sep}filter[admin]={admin}")?; sep = '&'; } + if let Some(legacy_guest) = self.legacy_guest { + write!(f, "{sep}filter[legacy-guest]={legacy_guest}")?; + sep = '&'; + } + if let Some(search) = &self.search { + write!(f, "{sep}filter[search]={search}")?; + sep = '&'; + } if let Some(status) = self.status { write!(f, "{sep}filter[status]={status}")?; sep = '&'; @@ -118,23 +134,35 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let users = User::samples(); let pagination = mas_storage::Pagination::first(users.len()); let page = Page { - edges: users.into(), + edges: users + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of users") - .example(PaginatedResponse::new(page, pagination, 42, User::PATH)) + .example(PaginatedResponse::for_page( + page, + pagination, + Some(42), + User::PATH, + )) }) } #[tracing::instrument(name = "handler.admin.v1.users.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = User::PATH); + let base = include_count.add_to_base(&base); let filter = UserFilter::default(); let filter = match params.admin { @@ -143,6 +171,17 @@ pub async fn handler( None => filter, }; + let filter = match params.legacy_guest { + Some(true) => filter.guest_only(), + Some(false) => filter.non_guest_only(), + None => filter, + }; + + let filter = match params.search.as_deref() { + Some(search) => filter.matching_search(search), + None => filter, + }; + let filter = match params.status { Some(UserStatus::Active) => filter.active_only(), Some(UserStatus::Locked) => filter.locked_only(), @@ -150,13 +189,243 @@ pub async fn handler( None => filter, }; - let page = repo.user().list(filter, pagination).await?; - let count = repo.user().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo.user().list(filter, pagination).await?; + let count = repo.user().count(filter).await?; + PaginatedResponse::for_page(page.map(User::from), pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo.user().list(filter, pagination).await?; + PaginatedResponse::for_page(page.map(User::from), pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.user().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(User::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_users(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision two users + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.user() + .add(&mut rng, &state.clock, "bob".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Test default behavior (count=true) + let request = Request::get("/api/admin/v1/users").bearer(&token).empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "username": "bob", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + }, + { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?page[first]=10", + "first": "/api/admin/v1/users?page[first]=10", + "last": "/api/admin/v1/users?page[last]=10" + } + } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/users?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "username": "bob", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + }, + { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?count=false&page[first]=10", + "first": "/api/admin/v1/users?count=false&page[first]=10", + "last": "/api/admin/v1/users?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/users?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/users?count=only" + } + } + "###); + + // Test count=false with filtering + let request = Request::get("/api/admin/v1/users?count=false&filter[search]=alice") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?filter[search]=alice&count=false&page[first]=10", + "first": "/api/admin/v1/users?filter[search]=alice&count=false&page[first]=10", + "last": "/api/admin/v1/users?filter[search]=alice&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get("/api/admin/v1/users?count=only&filter[search]=alice") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/users?filter[search]=alice&count=only" + } + } + "#); + } } diff --git a/crates/handlers/src/admin/v1/users/lock.rs b/crates/handlers/src/admin/v1/users/lock.rs index 13ffdc071..6d6ccfcf9 100644 --- a/crates/handlers/src/admin/v1/users/lock.rs +++ b/crates/handlers/src/admin/v1/users/lock.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; @@ -72,15 +72,13 @@ pub async fn handler( id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; - let mut user = repo + let user = repo .user() .lookup(id) .await? .ok_or(RouteError::NotFound(id))?; - if user.locked_at.is_none() { - user = repo.user().lock(&clock, user).await?; - } + let user = repo.user().lock(&clock, user).await?; repo.save().await?; @@ -94,7 +92,8 @@ pub async fn handler( mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_storage::{Clock, RepositoryAccess, user::UserRepository}; + use mas_data_model::Clock; + use mas_storage::{RepositoryAccess, user::UserRepository}; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -157,6 +156,10 @@ mod tests { body["data"]["attributes"]["locked_at"], serde_json::json!(state.clock.now()) ); + assert_ne!( + body["data"]["attributes"]["locked_at"], + serde_json::Value::Null + ); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/users/mod.rs b/crates/handlers/src/admin/v1/users/mod.rs index ff610f167..37484b75b 100644 --- a/crates/handlers/src/admin/v1/users/mod.rs +++ b/crates/handlers/src/admin/v1/users/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod add; mod by_username; @@ -10,6 +10,7 @@ mod deactivate; mod get; mod list; mod lock; +mod reactivate; mod set_admin; mod set_password; mod unlock; @@ -21,6 +22,7 @@ pub use self::{ get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, lock::{doc as lock_doc, handler as lock}, + reactivate::{doc as reactivate_doc, handler as reactivate}, set_admin::{doc as set_admin_doc, handler as set_admin}, set_password::{doc as set_password_doc, handler as set_password}, unlock::{doc as unlock_doc, handler as unlock}, diff --git a/crates/handlers/src/admin/v1/users/reactivate.rs b/crates/handlers/src/admin/v1/users/reactivate.rs new file mode 100644 index 000000000..835ef0b40 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/reactivate.rs @@ -0,0 +1,222 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::sync::Arc; + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, extract::State, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_matrix::HomeserverConnection; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, User}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error(transparent)] + Homeserver(anyhow::Error), + + #[error("User ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_)); + let status = match self { + Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("reactivateUser") + .summary("Reactivate a user") + .description("Calling this endpoint will reactivate a deactivated user. +This DOES NOT unlock a locked user, which is still prevented from doing any action until it is explicitly unlocked.") + .tag("user") + .response_with::<200, Json>, _>(|t| { + // In the samples, the third user is the one locked + let [sample, ..] = User::samples(); + let id = sample.id(); + let response = SingleResponse::new(sample, format!("/api/admin/v1/users/{id}/reactivate")); + t.description("User was reactivated").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User ID not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.users.reactivate", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + State(homeserver): State>, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let user = repo + .user() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Call the homeserver synchronously to reactivate the user + homeserver + .reactivate_user(&user.username) + .await + .map_err(RouteError::Homeserver)?; + + // Now reactivate the user in our database + let user = repo.user().reactivate(user).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + User::from(user), + format!("/api/admin/v1/users/{id}/reactivate"), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_data_model::Clock; + use mas_matrix::{HomeserverConnection, ProvisionRequest}; + use mas_storage::{RepositoryAccess, user::UserRepository}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_reactivate_deactivated_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + let user = repo.user().lock(&state.clock, user).await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + // Provision and immediately deactivate the user on the homeserver, + // because this endpoint will try to reactivate it + state + .homeserver_connection + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .await + .unwrap(); + state + .homeserver_connection + .delete_user(&user.username, true) + .await + .unwrap(); + + // The user should be deactivated on the homeserver + let mx_user = state + .homeserver_connection + .query_user(&user.username) + .await + .unwrap(); + assert!(mx_user.deactivated); + + let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The user should remain locked after being reactivated + assert_eq!( + body["data"]["attributes"]["locked_at"], + serde_json::json!(state.clock.now()) + ); + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::Value::Null, + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_reactivate_active_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Provision the user on the homeserver + state + .homeserver_connection + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .await + .unwrap(); + + let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_eq!( + body["data"]["attributes"]["locked_at"], + serde_json::Value::Null + ); + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::Value::Null + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_reactivate_unknown_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/reactivate") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "User ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/users/set_admin.rs b/crates/handlers/src/admin/v1/users/set_admin.rs index 72df1f71b..455fa7988 100644 --- a/crates/handlers/src/admin/v1/users/set_admin.rs +++ b/crates/handlers/src/admin/v1/users/set_admin.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; diff --git a/crates/handlers/src/admin/v1/users/set_password.rs b/crates/handlers/src/admin/v1/users/set_password.rs index 4de8acd73..7e9365f73 100644 --- a/crates/handlers/src/admin/v1/users/set_password.rs +++ b/crates/handlers/src/admin/v1/users/set_password.rs @@ -1,14 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_storage::BoxRng; +use mas_data_model::BoxRng; use schemars::JsonSchema; use serde::Deserialize; use ulid::Ulid; @@ -141,7 +141,7 @@ mod tests { use zeroize::Zeroizing; use crate::{ - passwords::PasswordManager, + passwords::{PasswordManager, PasswordVerificationResult}, test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}, }; @@ -181,7 +181,7 @@ mod tests { let mut repo = state.repository().await.unwrap(); let user_password = repo.user_password().active(&user).await.unwrap().unwrap(); let password = Zeroizing::new(String::from("this is a good enough password")); - state + let res = state .password_manager .verify( user_password.version, @@ -190,6 +190,7 @@ mod tests { ) .await .unwrap(); + assert_eq!(res, PasswordVerificationResult::Success(())); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] @@ -240,7 +241,7 @@ mod tests { let mut repo = state.repository().await.unwrap(); let user_password = repo.user_password().active(&user).await.unwrap().unwrap(); let password = Zeroizing::new("password".to_owned()); - state + let res = state .password_manager .verify( user_password.version, @@ -249,6 +250,7 @@ mod tests { ) .await .unwrap(); + assert_eq!(res, PasswordVerificationResult::Success(())); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/users/unlock.rs b/crates/handlers/src/admin/v1/users/unlock.rs index e1811378c..72987a9ff 100644 --- a/crates/handlers/src/admin/v1/users/unlock.rs +++ b/crates/handlers/src/admin/v1/users/unlock.rs @@ -1,16 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -use std::sync::Arc; +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, extract::State, response::IntoResponse}; +use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_matrix::HomeserverConnection; use ulid::Ulid; use crate::{ @@ -29,9 +26,6 @@ pub enum RouteError { #[error(transparent)] Internal(Box), - #[error(transparent)] - Homeserver(anyhow::Error), - #[error("User ID {0} not found")] NotFound(Ulid), } @@ -41,9 +35,9 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { let error = ErrorResponse::from_error(&self); - let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_)); + let sentry_event_id = record_error!(self, Self::Internal(_)); let status = match self { - Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::NotFound(_) => StatusCode::NOT_FOUND, }; (status, sentry_event_id, Json(error)).into_response() @@ -54,6 +48,8 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { operation .id("unlockUser") .summary("Unlock a user") + .description("Calling this endpoint will lift restrictions on user actions that had imposed by locking. +This DOES NOT reactivate a deactivated user, which will remain unavailable until it is explicitly reactivated.") .tag("user") .response_with::<200, Json>, _>(|t| { // In the samples, the third user is the one locked @@ -71,7 +67,6 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - State(homeserver): State>, id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; @@ -81,14 +76,6 @@ pub async fn handler( .await? .ok_or(RouteError::NotFound(id))?; - // Call the homeserver synchronously to unlock the user - let mxid = homeserver.mxid(&user.username); - homeserver - .reactivate_user(&mxid) - .await - .map_err(RouteError::Homeserver)?; - - // Now unlock the user in our database let user = repo.user().unlock(user).await?; repo.save().await?; @@ -102,6 +89,7 @@ pub async fn handler( #[cfg(test)] mod tests { use hyper::{Request, StatusCode}; + use mas_data_model::Clock; use mas_matrix::{HomeserverConnection, ProvisionRequest}; use mas_storage::{RepositoryAccess, user::UserRepository}; use sqlx::PgPool; @@ -125,10 +113,9 @@ mod tests { // Also provision the user on the homeserver, because this endpoint will try to // reactivate it - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(&mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); @@ -141,7 +128,7 @@ mod tests { assert_eq!( body["data"]["attributes"]["locked_at"], - serde_json::json!(null) + serde_json::Value::Null ); } @@ -158,24 +145,28 @@ mod tests { .await .unwrap(); let user = repo.user().lock(&state.clock, user).await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); repo.save().await.unwrap(); // Provision the user on the homeserver - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(&mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); // but then deactivate it state .homeserver_connection - .delete_user(&mxid, true) + .delete_user(&user.username, true) .await .unwrap(); // The user should be deactivated on the homeserver - let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap(); + let mx_user = state + .homeserver_connection + .query_user(&user.username) + .await + .unwrap(); assert!(mx_user.deactivated); let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id)) @@ -187,11 +178,19 @@ mod tests { assert_eq!( body["data"]["attributes"]["locked_at"], - serde_json::json!(null) + serde_json::Value::Null ); - // The user should be reactivated on the homeserver - let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap(); - assert!(!mx_user.deactivated); + // The user should remain deactivated + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::json!(state.clock.now()) + ); + let mx_user = state + .homeserver_connection + .query_user(&user.username) + .await + .unwrap(); + assert!(mx_user.deactivated); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/version.rs b/crates/handlers/src/admin/v1/version.rs new file mode 100644 index 000000000..2fe53940b --- /dev/null +++ b/crates/handlers/src/admin/v1/version.rs @@ -0,0 +1,62 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::transform::TransformOperation; +use axum::{Json, extract::State}; +use mas_data_model::AppVersion; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::admin::call_context::CallContext; + +#[derive(Serialize, JsonSchema)] +pub struct Version { + /// The semver version of the app + pub version: &'static str, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("version") + .tag("server") + .summary("Get the version currently running") + .response_with::<200, Json, _>(|t| t.example(Version { version: "v1.0.0" })) +} + +#[tracing::instrument(name = "handler.admin.v1.version", skip_all)] +pub async fn handler( + _: CallContext, + State(AppVersion(version)): State, +) -> Json { + Json(Version { version }) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::get("/api/admin/v1/version").bearer(&token).empty(); + + let response = state.request(request).await; + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "version": "v0.0.0-test" + } + "#); + } +} diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index a3f59c4c6..1b73c05c3 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![forbid(unsafe_code)] #![deny( @@ -50,8 +50,8 @@ macro_rules! impl_from_ref { } impl_from_request_parts!(mas_storage::BoxRepository); -impl_from_request_parts!(mas_storage::BoxClock); -impl_from_request_parts!(mas_storage::BoxRng); +impl_from_request_parts!(mas_data_model::BoxClock); +impl_from_request_parts!(mas_data_model::BoxRng); impl_from_request_parts!(mas_handlers::BoundActivityTracker); impl_from_ref!(mas_router::UrlBuilder); impl_from_ref!(mas_templates::Templates); @@ -59,6 +59,8 @@ impl_from_ref!(Arc); impl_from_ref!(mas_keystore::Keystore); impl_from_ref!(mas_handlers::passwords::PasswordManager); impl_from_ref!(Arc); +impl_from_ref!(mas_data_model::SiteConfig); +impl_from_ref!(mas_data_model::AppVersion); fn main() -> Result<(), Box> { let (mut api, _) = mas_handlers::admin_api_router::(); diff --git a/crates/handlers/src/bin/graphql-schema.rs b/crates/handlers/src/bin/graphql-schema.rs index d2ac8a642..45bdcc175 100644 --- a/crates/handlers/src/bin/graphql-schema.rs +++ b/crates/handlers/src/bin/graphql-schema.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![forbid(unsafe_code)] #![deny( diff --git a/crates/handlers/src/captcha.rs b/crates/handlers/src/captcha.rs index 740995145..c206df5cf 100644 --- a/crates/handlers/src/captcha.rs +++ b/crates/handlers/src/captcha.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 32bdfacd3..d3c7c979f 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock}; @@ -11,10 +11,13 @@ use axum_extra::typed_header::TypedHeader; use chrono::Duration; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_data_model::{CompatSession, CompatSsoLoginState, Device, SiteConfig, TokenType, User}; +use mas_data_model::{ + BoxClock, BoxRng, Clock, CompatSession, CompatSsoLoginState, Device, SiteConfig, TokenType, + User, +}; use mas_matrix::HomeserverConnection; use mas_storage::{ - BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, Clock, RepositoryAccess, + BoxRepository, BoxRepositoryFactory, RepositoryAccess, compat::{ CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository, CompatSsoLoginRepository, @@ -32,7 +35,8 @@ use zeroize::Zeroizing; use super::{MatrixError, MatrixJsonBody}; use crate::{ BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route, - passwords::PasswordManager, rate_limit::PasswordCheckLimitedError, + passwords::{PasswordManager, PasswordVerificationResult}, + rate_limit::PasswordCheckLimitedError, }; static LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { @@ -193,7 +197,7 @@ pub enum RouteError { NoPassword, #[error("password verification failed")] - PasswordVerificationFailed(#[source] anyhow::Error), + PasswordMismatch, #[error("request rate limited")] RateLimited(#[from] PasswordCheckLimitedError), @@ -204,12 +208,21 @@ pub enum RouteError { #[error("invalid login token")] InvalidLoginToken, + #[error("user is locked")] + UserLocked, + #[error("failed to provision device")] ProvisionDeviceFailed(#[source] anyhow::Error), } impl_from_error_for_route!(mas_storage::RepositoryError); +impl From for RouteError { + fn from(err: anyhow::Error) -> Self { + Self::Internal(err.into()) + } +} + impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { let sentry_event_id = @@ -241,13 +254,11 @@ impl IntoResponse for RouteError { error: "Missing property 'identifier", status: StatusCode::BAD_REQUEST, }, - Self::UserNotFound | Self::NoPassword | Self::PasswordVerificationFailed(_) => { - MatrixError { - errcode: "M_FORBIDDEN", - error: "Invalid username/password", - status: StatusCode::FORBIDDEN, - } - } + Self::UserNotFound | Self::NoPassword | Self::PasswordMismatch => MatrixError { + errcode: "M_FORBIDDEN", + error: "Invalid username/password", + status: StatusCode::FORBIDDEN, + }, Self::LoginTookTooLong => MatrixError { errcode: "M_FORBIDDEN", error: "Login token expired", @@ -258,6 +269,11 @@ impl IntoResponse for RouteError { error: "Invalid login token", status: StatusCode::FORBIDDEN, }, + Self::UserLocked => MatrixError { + errcode: "M_USER_LOCKED", + error: "User account has been locked", + status: StatusCode::UNAUTHORIZED, + }, }; (sentry_event_id, response).into_response() @@ -398,7 +414,11 @@ pub(crate) async fn post( // Now we can create the device on the homeserver, without holding the // transaction if let Err(err) = homeserver - .create_device(&user_id, device.as_str(), session.human_name.as_deref()) + .upsert_device( + &user.username, + device.as_str(), + session.human_name.as_deref(), + ) .await { // Something went wrong, let's end this session and schedule a device sync @@ -501,7 +521,15 @@ async fn token_login( browser_session.id = %browser_session_id, "Attempt to exchange login token but browser session is not active" ); - return Err(RouteError::InvalidLoginToken); + return Err( + if browser_session.finished_at.is_some() + || browser_session.user.deactivated_at.is_some() + { + RouteError::InvalidLoginToken + } else { + RouteError::UserLocked + }, + ); } // We're about to create a device, let's explicitly acquire a lock, so that @@ -560,9 +588,13 @@ async fn user_password_login( .user() .find_by_username(username) .await? - .filter(mas_data_model::User::is_valid) + .filter(|user| user.deactivated_at.is_none()) .ok_or(RouteError::UserNotFound)?; + if user.locked_at.is_some() { + return Err(RouteError::UserLocked); + } + // Check the rate limit limiter.check_password(requester, &user)?; @@ -576,28 +608,32 @@ async fn user_password_login( // Verify the password let password = Zeroizing::new(password); - let new_password_hash = password_manager + match password_manager .verify_and_upgrade( &mut rng, user_password.version, password, user_password.hashed_password.clone(), ) - .await - .map_err(RouteError::PasswordVerificationFailed)?; - - if let Some((version, hashed_password)) = new_password_hash { - // Save the upgraded password if needed - repo.user_password() - .add( - &mut rng, - clock, - &user, - version, - hashed_password, - Some(&user_password), - ) - .await?; + .await? + { + PasswordVerificationResult::Success(Some((version, hashed_password))) => { + // Save the upgraded password if needed + repo.user_password() + .add( + &mut rng, + clock, + &user, + version, + hashed_password, + Some(&user_password), + ) + .await?; + } + PasswordVerificationResult::Success(None) => {} + PasswordVerificationResult::Failure => { + return Err(RouteError::PasswordMismatch); + } } // We're about to create a device, let's explicitly acquire a lock, so that @@ -776,7 +812,12 @@ mod tests { "###); } - async fn user_with_password(state: &TestState, username: &str, password: &str) { + async fn user_with_password( + state: &TestState, + username: &str, + password: &str, + locked: bool, + ) -> User { let mut rng = state.rng(); let mut repo = state.repository().await.unwrap(); @@ -795,14 +836,20 @@ mod tests { .add(&mut rng, &state.clock, &user, version, hash, None) .await .unwrap(); - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); + let user = if locked { + repo.user().lock(&state.clock, user).await.unwrap() + } else { + user + }; + repo.save().await.unwrap(); + user } /// Test that a user can login with a password using the Matrix @@ -812,7 +859,7 @@ mod tests { setup(); let state = TestState::from_pool(pool).await.unwrap(); - user_with_password(&state, "alice", "password").await; + let user = user_with_password(&state, "alice", "password", true).await; // Now let's try to login with the password, without asking for a refresh token. let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ @@ -824,14 +871,30 @@ mod tests { "password": "password", })); + // First try to login to a locked account + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_USER_LOCKED", + "error": "User account has been locked" + } + "###); + + // Now try again after unlocking the account + let mut repo = state.repository().await.unwrap(); + let user = repo.user().unlock(user).await.unwrap(); + repo.save().await.unwrap(); + let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_16tugBE5Ta9LIWoSJaAEHHq2g3fx8S_alcBB4", - "device_id": "ZGpSvYQqlq", + "access_token": "mct_cxG6gZXyvelQWW9XqfNbm5KAQovodf_XvJz43", + "device_id": "42oTpLoieH", "user_id": "@alice:example.com" } "###); @@ -853,10 +916,10 @@ mod tests { let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_cxG6gZXyvelQWW9XqfNbm5KAQovodf_XvJz43", - "device_id": "42oTpLoieH", + "access_token": "mct_PGMLvvMXC4Ds1A3lCWc6Hx4l9DGzqG_lVEIV2", + "device_id": "Yp7FM44zJN", "user_id": "@alice:example.com", - "refresh_token": "mcr_7IvDc44woP66fRQoS9MVcHXO9OeBmR_0jDGr1", + "refresh_token": "mcr_LoYqtrtBUBcWlE4RX6o47chBCGkadB_9gzpc1", "expires_in_ms": 300000 } "###); @@ -874,8 +937,8 @@ mod tests { let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_PGMLvvMXC4Ds1A3lCWc6Hx4l9DGzqG_lVEIV2", - "device_id": "Yp7FM44zJN", + "access_token": "mct_Xl3bbpfh9yNy9NzuRxyR3b3PLW0rqd_DiXAH2", + "device_id": "6cq7FqNSYo", "user_id": "@alice:example.com" } "###); @@ -921,6 +984,45 @@ mod tests { // The response should be the same as the previous one, so that we don't leak if // it's the user that is invalid or the password. assert_eq!(body, old_body); + + // Try to login to a deactivated account + let mut repo = state.repository().await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid username/password" + } + "###); + + // Should get the same error if the deactivated user is also locked + let mut repo = state.repository().await.unwrap(); + let _user = repo.user().lock(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid username/password" + } + "###); } /// Test that we can send a login request without a Content-Type header @@ -929,7 +1031,7 @@ mod tests { setup(); let state = TestState::from_pool(pool).await.unwrap(); - user_with_password(&state, "alice", "password").await; + user_with_password(&state, "alice", "password", false).await; // Try without a Content-Type header let mut request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ "type": "m.login.password", @@ -961,7 +1063,7 @@ mod tests { setup(); let state = TestState::from_pool(pool).await.unwrap(); - user_with_password(&state, "alice", "password").await; + let user = user_with_password(&state, "alice", "password", true).await; // Login with a full MXID as identifier let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ @@ -973,13 +1075,29 @@ mod tests { "password": "password", })); + // First try to login to a locked account + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_USER_LOCKED", + "error": "User account has been locked" + } + "###); + + // Now try again after unlocking the account + let mut repo = state.repository().await.unwrap(); + let _ = repo.user().unlock(user).await.unwrap(); + repo.save().await.unwrap(); + let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_16tugBE5Ta9LIWoSJaAEHHq2g3fx8S_alcBB4", - "device_id": "ZGpSvYQqlq", + "access_token": "mct_cxG6gZXyvelQWW9XqfNbm5KAQovodf_XvJz43", + "device_id": "42oTpLoieH", "user_id": "@alice:example.com" } "###); @@ -1021,10 +1139,9 @@ mod tests { .await .unwrap(); - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); @@ -1123,12 +1240,13 @@ mod tests { .add(&mut state.rng(), &state.clock, "alice".to_owned()) .await .unwrap(); + // Start with a locked account + let user = repo.user().lock(&state.clock, user).await.unwrap(); repo.save().await.unwrap(); - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); @@ -1155,14 +1273,29 @@ mod tests { "type": "m.login.token", "token": token, })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_USER_LOCKED", + "error": "User account has been locked" + } + "###); + + // Now try again after unlocking the account + let mut repo = state.repository().await.unwrap(); + let user = repo.user().unlock(user).await.unwrap(); + repo.save().await.unwrap(); + let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r#" { - "access_token": "mct_bnkWh1tPmm1MZOpygPaXwygX8PfxEY_hE6do1", - "device_id": "O3Ju1MUh3Z", + "access_token": "mct_bUTa4XIh92RARTPTjqQrCZLAkq2ild_0VsYE6", + "device_id": "uihy4bk51g", "user_id": "@alice:example.com" } "#); @@ -1203,6 +1336,41 @@ mod tests { "error": "Login token expired" } "###); + + // Try to login to a deactivated account + let token = get_login_token(&state, &user).await; + + let mut repo = state.repository().await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.token", + "token": token, + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid login token" + } + "###); + + // Should get the same error if the deactivated user is also locked + let mut repo = state.repository().await.unwrap(); + let _user = repo.user().lock(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid login token" + } + "###); } /// Get a login token for a user. diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 15da5fb4d..a4fbb24fb 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -1,26 +1,26 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; use anyhow::Context; use axum::{ - extract::{Form, Path, Query, State}, + extract::{Form, Path, State}, response::{Html, IntoResponse, Redirect, Response}, }; +use axum_extra::extract::Query; use chrono::Duration; use mas_axum_utils::{ InternalError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; +use mas_data_model::{BoxClock, BoxRng, Clock}; use mas_router::{CompatLoginSsoAction, UrlBuilder}; -use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, compat::CompatSsoLoginRepository, -}; +use mas_storage::{BoxRepository, RepositoryAccess, compat::CompatSsoLoginRepository}; use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates}; use serde::{Deserialize, Serialize}; use ulid::Ulid; diff --git a/crates/handlers/src/compat/login_sso_redirect.rs b/crates/handlers/src/compat/login_sso_redirect.rs index 583f24e9b..f085bb82f 100644 --- a/crates/handlers/src/compat/login_sso_redirect.rs +++ b/crates/handlers/src/compat/login_sso_redirect.rs @@ -1,17 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -use axum::{ - extract::{Query, State}, - response::IntoResponse, -}; +use axum::{extract::State, response::IntoResponse}; +use axum_extra::extract::Query; use hyper::StatusCode; -use mas_axum_utils::record_error; +use mas_axum_utils::{GenericError, InternalError}; +use mas_data_model::{BoxClock, BoxRng}; use mas_router::{CompatLoginSsoAction, CompatLoginSsoComplete, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng, compat::CompatSsoLoginRepository}; +use mas_storage::{BoxRepository, compat::CompatSsoLoginRepository}; use rand::distributions::{Alphanumeric, DistString}; use serde::Deserialize; use serde_with::serde; @@ -43,12 +42,12 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let sentry_event_id = record_error!(self, Self::Internal(_)); - let status_code = match &self { - Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::MissingRedirectUrl | Self::InvalidRedirectUrl => StatusCode::BAD_REQUEST, - }; - (status_code, sentry_event_id, format!("{self}")).into_response() + match self { + Self::Internal(e) => InternalError::new(e).into_response(), + Self::MissingRedirectUrl | Self::InvalidRedirectUrl => { + GenericError::new(StatusCode::BAD_REQUEST, self).into_response() + } + } } } diff --git a/crates/handlers/src/compat/logout.rs b/crates/handlers/src/compat/logout.rs index 7b2ea7d52..4642cc54b 100644 --- a/crates/handlers/src/compat/logout.rs +++ b/crates/handlers/src/compat/logout.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; @@ -11,9 +11,9 @@ use axum_extra::typed_header::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_data_model::TokenType; +use mas_data_model::{BoxClock, BoxRng, Clock, TokenType}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, + BoxRepository, RepositoryAccess, compat::{CompatAccessTokenRepository, CompatSessionRepository}, queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, }; diff --git a/crates/handlers/src/compat/logout_all.rs b/crates/handlers/src/compat/logout_all.rs index 489521313..f2ec10a82 100644 --- a/crates/handlers/src/compat/logout_all.rs +++ b/crates/handlers/src/compat/logout_all.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; @@ -10,9 +10,9 @@ use axum_extra::typed_header::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_data_model::TokenType; +use mas_data_model::{BoxClock, BoxRng, Clock, TokenType}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, + BoxRepository, RepositoryAccess, compat::{CompatAccessTokenRepository, CompatSessionFilter, CompatSessionRepository}, queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, }; diff --git a/crates/handlers/src/compat/mod.rs b/crates/handlers/src/compat/mod.rs index abf02a28c..1c30d5e04 100644 --- a/crates/handlers/src/compat/mod.rs +++ b/crates/handlers/src/compat/mod.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ Json, diff --git a/crates/handlers/src/compat/refresh.rs b/crates/handlers/src/compat/refresh.rs index 7511e0e99..95679af1d 100644 --- a/crates/handlers/src/compat/refresh.rs +++ b/crates/handlers/src/compat/refresh.rs @@ -1,16 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{Json, extract::State, response::IntoResponse}; use chrono::Duration; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_data_model::{SiteConfig, TokenFormatError, TokenType}; +use mas_data_model::{BoxClock, BoxRng, Clock, SiteConfig, TokenFormatError, TokenType}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, + BoxRepository, compat::{CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository}, }; use serde::{Deserialize, Serialize}; diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index cfedd69e9..7ccf9e51b 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::module_name_repetitions)] @@ -28,13 +28,13 @@ use hyper::header::CACHE_CONTROL; use mas_axum_utils::{ InternalError, SessionInfo, SessionInfoExt, cookies::CookieJar, sentry::SentryEventID, }; -use mas_data_model::{BrowserSession, Session, SiteConfig, User}; +use mas_data_model::{ + BoxClock, BoxRng, BrowserSession, Clock, Session, SiteConfig, SystemClock, User, +}; use mas_matrix::HomeserverConnection; use mas_policy::{InstantiateError, Policy, PolicyFactory}; use mas_router::UrlBuilder; -use mas_storage::{ - BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, Clock, RepositoryError, SystemClock, -}; +use mas_storage::{BoxRepository, BoxRepositoryFactory, RepositoryError}; use opentelemetry_semantic_conventions::trace::{GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME}; use rand::{SeedableRng, thread_rng}; use rand_chacha::ChaChaRng; @@ -341,8 +341,7 @@ pub async fn post( let request = async_graphql::http::receive_body( content_type, - body.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) - .into_async_read(), + body.map_err(std::io::Error::other).into_async_read(), MultipartOptions::default(), ) .await? diff --git a/crates/handlers/src/graphql/model/browser_sessions.rs b/crates/handlers/src/graphql/model/browser_sessions.rs index 5e15644e2..08ba25830 100644 --- a/crates/handlers/src/graphql/model/browser_sessions.rs +++ b/crates/handlers/src/graphql/model/browser_sessions.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{ Context, Description, ID, Object, @@ -172,7 +172,7 @@ impl BrowserSession { connection .edges - .extend(page.edges.into_iter().map(|s| match s { + .extend(page.edges.into_iter().map(|edge| match edge.node { mas_storage::app_session::AppSession::Compat(session) => Edge::new( OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), AppSession::CompatSession(Box::new(CompatSession::new(*session))), diff --git a/crates/handlers/src/graphql/model/compat_sessions.rs b/crates/handlers/src/graphql/model/compat_sessions.rs index 90adb61fe..fbbd4ab1f 100644 --- a/crates/handlers/src/graphql/model/compat_sessions.rs +++ b/crates/handlers/src/graphql/model/compat_sessions.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, Object}; diff --git a/crates/handlers/src/graphql/model/cursor.rs b/crates/handlers/src/graphql/model/cursor.rs index 6dba9f4db..b7b498d0f 100644 --- a/crates/handlers/src/graphql/model/cursor.rs +++ b/crates/handlers/src/graphql/model/cursor.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::connection::OpaqueCursor; use serde::{Deserialize, Serialize}; diff --git a/crates/handlers/src/graphql/model/matrix.rs b/crates/handlers/src/graphql/model/matrix.rs index 930742285..7316c0d63 100644 --- a/crates/handlers/src/graphql/model/matrix.rs +++ b/crates/handlers/src/graphql/model/matrix.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::SimpleObject; use mas_matrix::HomeserverConnection; @@ -27,9 +27,9 @@ impl MatrixUser { conn: &C, user: &str, ) -> Result { - let mxid = conn.mxid(user); + let info = conn.query_user(user).await?; - let info = conn.query_user(&mxid).await?; + let mxid = conn.mxid(user); Ok(MatrixUser { mxid, diff --git a/crates/handlers/src/graphql/model/mod.rs b/crates/handlers/src/graphql/model/mod.rs index 5a3137edf..063a63fb0 100644 --- a/crates/handlers/src/graphql/model/mod.rs +++ b/crates/handlers/src/graphql/model/mod.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{Enum, Interface, Object, SimpleObject}; use chrono::{DateTime, Utc}; diff --git a/crates/handlers/src/graphql/model/node.rs b/crates/handlers/src/graphql/model/node.rs index aa61f4b62..e63d2b387 100644 --- a/crates/handlers/src/graphql/model/node.rs +++ b/crates/handlers/src/graphql/model/node.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{ID, Interface}; use serde::{Deserialize, Serialize}; diff --git a/crates/handlers/src/graphql/model/oauth.rs b/crates/handlers/src/graphql/model/oauth.rs index 9ec94c288..20a4d527f 100644 --- a/crates/handlers/src/graphql/model/oauth.rs +++ b/crates/handlers/src/graphql/model/oauth.rs @@ -1,15 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, Object}; use chrono::{DateTime, Utc}; use mas_storage::{oauth2::OAuth2ClientRepository, user::BrowserSessionRepository}; -use oauth2_types::{oidc::ApplicationType, scope::Scope}; -use ulid::Ulid; +use oauth2_types::oidc::ApplicationType; use url::Url; use super::{BrowserSession, NodeType, SessionState, User, UserAgent}; @@ -200,33 +199,3 @@ impl OAuth2Client { } } } - -/// An OAuth 2.0 consent represents the scope a user consented to grant to a -/// client. -#[derive(Description)] -pub struct OAuth2Consent { - scope: Scope, - client_id: Ulid, -} - -#[Object(use_type_description)] -impl OAuth2Consent { - /// Scope consented by the user for this client. - pub async fn scope(&self) -> String { - self.scope.to_string() - } - - /// OAuth 2.0 client for which the user granted access. - pub async fn client(&self, ctx: &Context<'_>) -> Result { - let state = ctx.state(); - let mut repo = state.repository().await?; - let client = repo - .oauth2_client() - .lookup(self.client_id) - .await? - .context("Could not load client")?; - repo.cancel().await?; - - Ok(OAuth2Client(client)) - } -} diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs index 3a958d495..d6966907e 100644 --- a/crates/handlers/src/graphql/model/site_config.rs +++ b/crates/handlers/src/graphql/model/site_config.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::str_to_string)] // ComplexObject macro uses &str.to_string() diff --git a/crates/handlers/src/graphql/model/upstream_oauth.rs b/crates/handlers/src/graphql/model/upstream_oauth.rs index a2061210b..faa30a755 100644 --- a/crates/handlers/src/graphql/model/upstream_oauth.rs +++ b/crates/handlers/src/graphql/model/upstream_oauth.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, ID, Object}; diff --git a/crates/handlers/src/graphql/model/users.rs b/crates/handlers/src/graphql/model/users.rs index fb54580a0..7e615df7d 100644 --- a/crates/handlers/src/graphql/model/users.rs +++ b/crates/handlers/src/graphql/model/users.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{ @@ -125,10 +125,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|u| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::CompatSsoLogin, u.id)), - CompatSsoLogin(u), + OpaqueCursor(NodeCursor(NodeType::CompatSsoLogin, edge.cursor)), + CompatSsoLogin(edge.node), ) })); @@ -219,14 +219,13 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection - .edges - .extend(page.edges.into_iter().map(|(session, sso_login)| { - Edge::new( - OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), - CompatSession::new(session).with_loaded_sso_login(sso_login), - ) - })); + connection.edges.extend(page.edges.into_iter().map(|edge| { + let (session, sso_login) = edge.node; + Edge::new( + OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), + CompatSession::new(session).with_loaded_sso_login(sso_login), + ) + })); Ok::<_, async_graphql::Error>(connection) }, @@ -305,10 +304,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|u| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::BrowserSession, u.id)), - BrowserSession(u), + OpaqueCursor(NodeCursor(NodeType::BrowserSession, edge.cursor)), + BrowserSession(edge.node), ) })); @@ -373,10 +372,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|u| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::UserEmail, u.id)), - UserEmail(u), + OpaqueCursor(NodeCursor(NodeType::UserEmail, edge.cursor)), + UserEmail(edge.node), ) })); @@ -480,10 +479,10 @@ impl User { PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|s| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::OAuth2Session, s.id)), - OAuth2Session(s), + OpaqueCursor(NodeCursor(NodeType::OAuth2Session, edge.cursor)), + OAuth2Session(edge.node), ) })); @@ -547,10 +546,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|s| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Link, s.id)), - UpstreamOAuth2Link::new(s), + OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Link, edge.cursor)), + UpstreamOAuth2Link::new(edge.node), ) })); @@ -689,13 +688,13 @@ impl User { connection .edges - .extend(page.edges.into_iter().map(|s| match s { + .extend(page.edges.into_iter().map(|edge| match edge.node { mas_storage::app_session::AppSession::Compat(session) => Edge::new( - OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), + OpaqueCursor(NodeCursor(NodeType::CompatSession, edge.cursor)), AppSession::CompatSession(Box::new(CompatSession::new(*session))), ), mas_storage::app_session::AppSession::OAuth2(session) => Edge::new( - OpaqueCursor(NodeCursor(NodeType::OAuth2Session, session.id)), + OpaqueCursor(NodeCursor(NodeType::OAuth2Session, edge.cursor)), AppSession::OAuth2Session(Box::new(OAuth2Session(*session))), ), })); diff --git a/crates/handlers/src/graphql/model/viewer/anonymous.rs b/crates/handlers/src/graphql/model/viewer/anonymous.rs index e3b4e5273..56506d959 100644 --- a/crates/handlers/src/graphql/model/viewer/anonymous.rs +++ b/crates/handlers/src/graphql/model/viewer/anonymous.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{ID, Object}; diff --git a/crates/handlers/src/graphql/model/viewer/mod.rs b/crates/handlers/src/graphql/model/viewer/mod.rs index 4623f8a3e..4eec8c77b 100644 --- a/crates/handlers/src/graphql/model/viewer/mod.rs +++ b/crates/handlers/src/graphql/model/viewer/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::Union; diff --git a/crates/handlers/src/graphql/mutations/browser_session.rs b/crates/handlers/src/graphql/mutations/browser_session.rs index 688e17dcb..551775dbf 100644 --- a/crates/handlers/src/graphql/mutations/browser_session.rs +++ b/crates/handlers/src/graphql/mutations/browser_session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{Context, Enum, ID, InputObject, Object}; use mas_storage::RepositoryAccess; diff --git a/crates/handlers/src/graphql/mutations/compat_session.rs b/crates/handlers/src/graphql/mutations/compat_session.rs index 3930b5670..973d46f05 100644 --- a/crates/handlers/src/graphql/mutations/compat_session.rs +++ b/crates/handlers/src/graphql/mutations/compat_session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Enum, ID, InputObject, Object}; @@ -187,10 +187,9 @@ impl CompatSessionMutations { .await?; // Update the device on the homeserver side - let mxid = homeserver.mxid(&user.username); if let Some(device) = session.device.as_ref() { homeserver - .update_device_display_name(&mxid, device.as_str(), &input.human_name) + .update_device_display_name(&user.username, device.as_str(), &input.human_name) .await .context("Failed to provision device")?; } diff --git a/crates/handlers/src/graphql/mutations/matrix.rs b/crates/handlers/src/graphql/mutations/matrix.rs index 8788b493c..f88668e2f 100644 --- a/crates/handlers/src/graphql/mutations/matrix.rs +++ b/crates/handlers/src/graphql/mutations/matrix.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; @@ -93,7 +93,6 @@ impl MatrixMutations { repo.cancel().await?; let conn = state.homeserver_connection(); - let mxid = conn.mxid(&user.username); if let Some(display_name) = &input.display_name { // Let's do some basic validation on the display name @@ -105,11 +104,11 @@ impl MatrixMutations { return Ok(SetDisplayNamePayload::Invalid); } - conn.set_displayname(&mxid, display_name) + conn.set_displayname(&user.username, display_name) .await .context("Failed to set display name")?; } else { - conn.unset_displayname(&mxid) + conn.unset_displayname(&user.username) .await .context("Failed to unset display name")?; } diff --git a/crates/handlers/src/graphql/mutations/mod.rs b/crates/handlers/src/graphql/mutations/mod.rs index 21fca3d6c..a84bf9210 100644 --- a/crates/handlers/src/graphql/mutations/mod.rs +++ b/crates/handlers/src/graphql/mutations/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod browser_session; mod compat_session; @@ -84,7 +84,7 @@ async fn verify_password_if_needed( password, user_password.hashed_password, ) - .await; + .await?; - Ok(res.is_ok()) + Ok(res.is_success()) } diff --git a/crates/handlers/src/graphql/mutations/oauth2_session.rs b/crates/handlers/src/graphql/mutations/oauth2_session.rs index 1d0282014..55723efc5 100644 --- a/crates/handlers/src/graphql/mutations/oauth2_session.rs +++ b/crates/handlers/src/graphql/mutations/oauth2_session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; @@ -78,7 +78,7 @@ pub struct EndOAuth2SessionInput { /// The payload of the `endOauth2Session` mutation. pub enum EndOAuth2SessionPayload { NotFound, - Ended(mas_data_model::Session), + Ended(Box), } /// The status of the `endOauth2Session` mutation. @@ -104,7 +104,7 @@ impl EndOAuth2SessionPayload { /// Returns the ended session. async fn oauth2_session(&self) -> Option { match self { - Self::Ended(session) => Some(OAuth2Session(session.clone())), + Self::Ended(session) => Some(OAuth2Session(*session.clone())), Self::NotFound => None, } } @@ -126,7 +126,7 @@ pub enum SetOAuth2SessionNamePayload { NotFound, /// The session was updated. - Updated(mas_data_model::Session), + Updated(Box), } /// The status of the `setOauth2SessionName` mutation. @@ -152,7 +152,7 @@ impl SetOAuth2SessionNamePayload { /// The session that was updated. async fn oauth2_session(&self) -> Option { match self { - Self::Updated(session) => Some(OAuth2Session(session.clone())), + Self::Updated(session) => Some(OAuth2Session(*session.clone())), Self::NotFound => None, } } @@ -212,11 +212,10 @@ impl OAuth2SessionMutations { repo.user().acquire_lock_for_sync(&user).await?; // Look for devices to provision - let mxid = homeserver.mxid(&user.username); for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str(), None) + .upsert_device(&user.username, device.as_str(), None) .await .context("Failed to provision device")?; } @@ -293,7 +292,7 @@ impl OAuth2SessionMutations { repo.save().await?; - Ok(EndOAuth2SessionPayload::Ended(session)) + Ok(EndOAuth2SessionPayload::Ended(Box::new(session))) } async fn set_oauth2_session_name( @@ -331,11 +330,10 @@ impl OAuth2SessionMutations { .await?; // Update the device on the homeserver side - let mxid = homeserver.mxid(&user.username); for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .update_device_display_name(&mxid, device.as_str(), &input.human_name) + .update_device_display_name(&user.username, device.as_str(), &input.human_name) .await .context("Failed to provision device")?; } @@ -343,6 +341,6 @@ impl OAuth2SessionMutations { repo.save().await?; - Ok(SetOAuth2SessionNamePayload::Updated(session)) + Ok(SetOAuth2SessionNamePayload::Updated(Box::new(session))) } } diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index f4ae549b9..355c7d0ac 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; @@ -563,7 +563,7 @@ impl UserMutations { Ok(LockUserPayload::Locked(user)) } - /// Unlock a user. This is only available to administrators. + /// Unlock and reactivate a user. This is only available to administrators. async fn unlock_user( &self, ctx: &Context<'_>, @@ -585,11 +585,11 @@ impl UserMutations { return Ok(UnlockUserPayload::NotFound); }; - // Call the homeserver synchronously to unlock the user - let mxid = matrix.mxid(&user.username); - matrix.reactivate_user(&mxid).await?; + // Call the homeserver synchronously to reactivate the user + matrix.reactivate_user(&user.username).await?; - // Now unlock the user in our database + // Now reactivate & unlock the user in our database + let user = repo.user().reactivate(user).await?; let user = repo.user().unlock(user).await?; repo.save().await?; @@ -653,9 +653,7 @@ impl UserMutations { }; let conn = state.homeserver_connection(); - let mxid = conn.mxid(&user.username); - - conn.allow_cross_signing_reset(&mxid) + conn.allow_cross_signing_reset(&user.username) .await .context("Failed to allow cross-signing reset")?; @@ -739,13 +737,14 @@ impl UserMutations { )); }; - if let Err(_err) = password_manager + if !password_manager .verify( active_password.version, Zeroizing::new(current_password_attempt), active_password.hashed_password, ) - .await + .await? + .is_success() { return Ok(SetPasswordPayload { status: SetPasswordStatus::WrongPassword, diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index 6f24f1ed4..63b825566 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; diff --git a/crates/handlers/src/graphql/query/mod.rs b/crates/handlers/src/graphql/query/mod.rs index eb86150e5..66e6b38bb 100644 --- a/crates/handlers/src/graphql/query/mod.rs +++ b/crates/handlers/src/graphql/query/mod.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{Context, ID, MergedObject, Object}; diff --git a/crates/handlers/src/graphql/query/session.rs b/crates/handlers/src/graphql/query/session.rs index 1115bed00..82ca55fd9 100644 --- a/crates/handlers/src/graphql/query/session.rs +++ b/crates/handlers/src/graphql/query/session.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{Context, ID, Object, Union}; use mas_data_model::Device; @@ -11,7 +11,6 @@ use mas_storage::{ compat::{CompatSessionFilter, CompatSessionRepository}, oauth2::OAuth2SessionFilter, }; -use oauth2_types::scope::Scope; use crate::graphql::{ UserId, @@ -69,7 +68,8 @@ impl SessionQuery { ); } - if let Some((compat_session, sso_login)) = compat_sessions.edges.into_iter().next() { + if let Some(edge) = compat_sessions.edges.into_iter().next() { + let (compat_session, sso_login) = edge.node; repo.cancel().await?; return Ok(Some(Session::CompatSession(Box::new( @@ -77,20 +77,11 @@ impl SessionQuery { )))); } - // Then, try to find an OAuth 2.0 session. Because we don't have any dedicated - // device column, we're looking up using the device scope. - // All device IDs can't necessarily be encoded as a scope. If it's not the case, - // we'll skip looking for OAuth 2.0 sessions. - let Ok(scope_token) = device.to_scope_token() else { - repo.cancel().await?; - - return Ok(None); - }; - let scope = Scope::from_iter([scope_token]); + // Then, try to find an OAuth 2.0 session. let filter = OAuth2SessionFilter::new() .for_user(&user) .active_only() - .with_scope(&scope); + .for_device(&device); let sessions = repo.oauth2_session().list(filter, pagination).await?; // It's possible to have multiple active OAuth 2.0 sessions. For now, we just @@ -102,10 +93,10 @@ impl SessionQuery { ); } - if let Some(session) = sessions.edges.into_iter().next() { + if let Some(edge) = sessions.edges.into_iter().next() { repo.cancel().await?; return Ok(Some(Session::OAuth2Session(Box::new(OAuth2Session( - session, + edge.node, ))))); } repo.cancel().await?; diff --git a/crates/handlers/src/graphql/query/upstream_oauth.rs b/crates/handlers/src/graphql/query/upstream_oauth.rs index b007425aa..f0b4ceee6 100644 --- a/crates/handlers/src/graphql/query/upstream_oauth.rs +++ b/crates/handlers/src/graphql/query/upstream_oauth.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{ Context, ID, Object, @@ -130,10 +130,10 @@ impl UpstreamOAuthQuery { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|p| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Provider, p.id)), - UpstreamOAuth2Provider::new(p), + OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Provider, edge.cursor)), + UpstreamOAuth2Provider::new(edge.node), ) })); diff --git a/crates/handlers/src/graphql/query/user.rs b/crates/handlers/src/graphql/query/user.rs index 9a2eeef45..bb55ef67b 100644 --- a/crates/handlers/src/graphql/query/user.rs +++ b/crates/handlers/src/graphql/query/user.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{ Context, Enum, ID, Object, @@ -143,11 +143,12 @@ impl UserQuery { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend( - page.edges.into_iter().map(|p| { - Edge::new(OpaqueCursor(NodeCursor(NodeType::User, p.id)), User(p)) - }), - ); + connection.edges.extend(page.edges.into_iter().map(|edge| { + Edge::new( + OpaqueCursor(NodeCursor(NodeType::User, edge.cursor)), + User(edge.node), + ) + })); Ok::<_, async_graphql::Error>(connection) }, diff --git a/crates/handlers/src/graphql/query/viewer.rs b/crates/handlers/src/graphql/query/viewer.rs index 6985dfd2e..defcb5571 100644 --- a/crates/handlers/src/graphql/query/viewer.rs +++ b/crates/handlers/src/graphql/query/viewer.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{Context, Object}; diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index 737f43340..7faf76334 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -1,15 +1,15 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_graphql::{Response, ServerError}; -use mas_data_model::SiteConfig; +use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryError}; +use mas_storage::{BoxRepository, RepositoryError}; use crate::{Limiter, graphql::Requester, passwords::PasswordManager}; diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index 08567314f..888d477d0 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -1,11 +1,12 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::http::Request; use hyper::StatusCode; +use mas_axum_utils::SessionInfoExt; use mas_data_model::{AccessToken, Client, TokenType, User}; use mas_matrix::{HomeserverConnection, ProvisionRequest}; use mas_router::SimpleRoute; @@ -19,11 +20,9 @@ use oauth2_types::{ scope::{OPENID, Scope, ScopeToken}, }; use sqlx::PgPool; +use zeroize::Zeroizing; -use crate::{ - test_utils, - test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}, -}; +use crate::test_utils::{self, CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}; async fn create_test_client(state: &TestState) -> Client { let mut repo = state.repository().await.unwrap(); @@ -348,7 +347,7 @@ async fn test_oauth2_admin(pool: PgPool) { } /// Test that we can query the GraphQL endpoint with a token from a -/// client_credentials grant. +/// `client_credentials` grant. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_oauth2_client_credentials(pool: PgPool) { setup(); @@ -529,10 +528,9 @@ async fn test_oauth2_client_credentials(pool: PgPool) { // XXX: we don't run the task worker here, so even though the addUser mutation // should have scheduled a job to provision the user, it won't run in the test, // so we need to do it manually - let mxid = state.homeserver_connection.mxid("alice"); state .homeserver_connection - .provision_user(&ProvisionRequest::new(mxid, user_id)) + .provision_user(&ProvisionRequest::new("alice", user_id)) .await .unwrap(); @@ -782,3 +780,301 @@ async fn test_add_user(pool: PgPool) { }) ); } + +/// Test the setPassword mutation where the current password provided is +/// wrong. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_set_password_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let user_id = user.id; + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": format!(r#" + mutation {{ + setPassword(input: {{ + userId: "user:{user_id}", + currentPassword: "wrong.password.123", + newPassword: "new.password.123" + }}) {{ + status + }} + }} + "#), + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["setPassword"]["status"].as_str(), + Some("WRONG_PASSWORD"), + "{:?}", + response.data + ); +} + +/// Test the startEmailAuthentication mutation where the current password +/// provided is invalid. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_start_email_authentication_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": r#" + mutation { + startEmailAuthentication(input: { + email: "alice@example.org", + password: "wrong.password.123" + }) { + status + } + } + "#, + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["startEmailAuthentication"]["status"].as_str(), + Some("INCORRECT_PASSWORD"), + "{:?}", + response.data + ); +} + +/// Test the removeEmail mutation where the current password +/// provided is invalid. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_remove_email_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let user_email_id = repo + .user_email() + .add( + &mut rng, + &state.clock, + &user, + "alice@example.org".to_owned(), + ) + .await + .unwrap() + .id; + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": format!(r#" + mutation {{ + removeEmail(input: {{ + userEmailId: "user_email:{user_email_id}", + password: "wrong.password.123" + }}) {{ + status + }} + }} + "#), + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["removeEmail"]["status"].as_str(), + Some("INCORRECT_PASSWORD"), + "{:?}", + response.data + ); +} + +/// Test the deactivateUser mutation where the current password +/// provided is invalid. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_deactivate_user_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": r#" + mutation { + deactivateUser(input: { + hsErase: true, + password: "wrong.password.123" + }) { + status + } + } + "#, + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["deactivateUser"]["status"].as_str(), + Some("INCORRECT_PASSWORD"), + "{:?}", + response.data + ); +} diff --git a/crates/handlers/src/health.rs b/crates/handlers/src/health.rs index b1dfbf993..916df60df 100644 --- a/crates/handlers/src/health.rs +++ b/crates/handlers/src/health.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{extract::State, response::IntoResponse}; use mas_axum_utils::InternalError; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 0605d6cd6..65a75f550 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(clippy::future_not_send)] #![allow( @@ -42,7 +42,7 @@ use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::{Route, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng}; +use mas_storage::{BoxRepository, BoxRepositoryFactory}; use mas_templates::{ErrorContext, NotFoundContext, TemplateContext, Templates}; use opentelemetry::metrics::Meter; use sqlx::PgPool; @@ -94,6 +94,7 @@ macro_rules! impl_from_error_for_route { } pub use mas_axum_utils::{ErrorWrapper, cookies::CookieManager}; +use mas_data_model::{BoxClock, BoxRng}; pub use self::{ activity_tracker::{ActivityTracker, Bound as BoundActivityTracker}, @@ -257,7 +258,7 @@ where } #[allow(clippy::trait_duplication_in_bounds)] -pub fn compat_router() -> Router +pub fn compat_router(templates: Templates) -> Router where S: Clone + Send + Sync + 'static, UrlBuilder: FromRef, @@ -272,7 +273,28 @@ where BoxClock: FromRequestParts, BoxRng: FromRequestParts, { - Router::new() + // A sub-router for human-facing routes with error handling + let human_router = Router::new() + .route( + mas_router::CompatLoginSsoRedirect::route(), + get(self::compat::login_sso_redirect::get), + ) + .route( + mas_router::CompatLoginSsoRedirectIdp::route(), + get(self::compat::login_sso_redirect::get), + ) + .route( + mas_router::CompatLoginSsoRedirectSlash::route(), + get(self::compat::login_sso_redirect::get), + ) + .layer(AndThenLayer::new( + async move |response: axum::response::Response| { + Ok::<_, Infallible>(recover_error(&templates, response)) + }, + )); + + // A sub-router for API-facing routes with CORS + let api_router = Router::new() .route( mas_router::CompatLogin::route(), get(self::compat::login::get).post(self::compat::login::post), @@ -289,18 +311,6 @@ where mas_router::CompatRefresh::route(), post(self::compat::refresh::post), ) - .route( - mas_router::CompatLoginSsoRedirect::route(), - get(self::compat::login_sso_redirect::get), - ) - .route( - mas_router::CompatLoginSsoRedirectIdp::route(), - get(self::compat::login_sso_redirect::get), - ) - .route( - mas_router::CompatLoginSsoRedirectSlash::route(), - get(self::compat::login_sso_redirect::get), - ) .layer( CorsLayer::new() .allow_origin(Any) @@ -314,10 +324,11 @@ where HeaderName::from_static("x-requested-with"), ]) .max_age(Duration::from_secs(60 * 60)), - ) + ); + + Router::new().merge(human_router).merge(api_router) } -#[allow(clippy::too_many_lines)] pub fn human_router(templates: Templates) -> Router where S: Clone + Send + Sync + 'static, @@ -440,6 +451,10 @@ where mas_router::UpstreamOAuth2Link::route(), get(self::upstream_oauth2::link::get).post(self::upstream_oauth2::link::post), ) + .route( + mas_router::UpstreamOAuth2BackchannelLogout::route(), + post(self::upstream_oauth2::backchannel_logout::post), + ) .route( mas_router::DeviceCodeLink::route(), get(self::oauth2::device::link::get), @@ -450,22 +465,29 @@ where ) .layer(AndThenLayer::new( async move |response: axum::response::Response| { - // Error responses should have an ErrorContext attached to them - let ext = response.extensions().get::(); - if let Some(ctx) = ext { - if let Ok(res) = templates.render_error(ctx) { - let (mut parts, _original_body) = response.into_parts(); - parts.headers.remove(CONTENT_TYPE); - parts.headers.remove(CONTENT_LENGTH); - return Ok((parts, Html(res)).into_response()); - } - } - - Ok::<_, Infallible>(response) + Ok::<_, Infallible>(recover_error(&templates, response)) }, )) } +fn recover_error( + templates: &Templates, + response: axum::response::Response, +) -> axum::response::Response { + // Error responses should have an ErrorContext attached to them + let ext = response.extensions().get::(); + if let Some(ctx) = ext + && let Ok(res) = templates.render_error(ctx) + { + let (mut parts, _original_body) = response.into_parts(); + parts.headers.remove(CONTENT_TYPE); + parts.headers.remove(CONTENT_LENGTH); + return (parts, Html(res)).into_response(); + } + + response +} + /// The fallback handler for all routes that don't match anything else. /// /// # Errors diff --git a/crates/handlers/src/oauth2/authorization/callback.rs b/crates/handlers/src/oauth2/authorization/callback.rs index beb0868d7..01f59d602 100644 --- a/crates/handlers/src/oauth2/authorization/callback.rs +++ b/crates/handlers/src/oauth2/authorization/callback.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::module_name_repetitions)] diff --git a/crates/handlers/src/oauth2/authorization/consent.rs b/crates/handlers/src/oauth2/authorization/consent.rs index 14dfd0e7f..968aec08a 100644 --- a/crates/handlers/src/oauth2/authorization/consent.rs +++ b/crates/handlers/src/oauth2/authorization/consent.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ extract::{Form, Path, State}, @@ -11,16 +11,16 @@ use axum::{ use axum_extra::TypedHeader; use hyper::StatusCode; use mas_axum_utils::{ + GenericError, InternalError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, - record_error, }; -use mas_data_model::AuthorizationGrantStage; +use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng}; use mas_keystore::Keystore; use mas_policy::Policy; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, + BoxRepository, oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, }; use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Templates}; @@ -64,13 +64,15 @@ impl_from_error_for_route!(super::callback::CallbackDestinationError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let sentry_event_id = record_error!(self, Self::Internal(_) | Self::NoSuchClient(_)); - ( - StatusCode::INTERNAL_SERVER_ERROR, - sentry_event_id, - self.to_string(), - ) - .into_response() + match self { + Self::Internal(e) => InternalError::new(e).into_response(), + e @ Self::NoSuchClient(_) => InternalError::new(Box::new(e)).into_response(), + e @ Self::GrantNotFound => GenericError::new(StatusCode::NOT_FOUND, e).into_response(), + e @ Self::GrantNotPending(_) => { + GenericError::new(StatusCode::CONFLICT, e).into_response() + } + e @ Self::Csrf(_) => GenericError::new(StatusCode::BAD_REQUEST, e).into_response(), + } } } diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index c3b080eae..6037bd9ad 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -1,19 +1,19 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ extract::{Form, State}, response::{IntoResponse, Response}, }; use hyper::StatusCode; -use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, record_error}; -use mas_data_model::{AuthorizationCode, Pkce}; +use mas_axum_utils::{GenericError, InternalError, SessionInfoExt, cookies::CookieJar}; +use mas_data_model::{AuthorizationCode, BoxClock, BoxRng, Pkce}; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, + BoxRepository, oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, }; use mas_templates::Templates; @@ -53,29 +53,15 @@ pub enum RouteError { impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let sentry_event_id = record_error!(self, Self::Internal(_)); - // TODO: better error pages - let response = match self { - RouteError::Internal(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + match self { + Self::Internal(e) => InternalError::new(e).into_response(), + e @ (Self::ClientNotFound + | Self::InvalidResponseMode + | Self::IntoCallbackDestination(_) + | Self::UnknownRedirectUri(_)) => { + GenericError::new(StatusCode::BAD_REQUEST, e).into_response() } - RouteError::ClientNotFound => { - (StatusCode::BAD_REQUEST, "could not find client").into_response() - } - RouteError::InvalidResponseMode => { - (StatusCode::BAD_REQUEST, "invalid response mode").into_response() - } - RouteError::IntoCallbackDestination(e) => { - (StatusCode::BAD_REQUEST, e.to_string()).into_response() - } - RouteError::UnknownRedirectUri(e) => ( - StatusCode::BAD_REQUEST, - format!("Invalid redirect URI ({e})"), - ) - .into_response(), - }; - - (sentry_event_id, response).into_response() + } } } @@ -123,7 +109,6 @@ fn resolve_response_mode( fields(client.id = %params.auth.client_id), skip_all, )] -#[allow(clippy::too_many_lines)] pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index 1feec8c3e..415131617 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; @@ -13,9 +13,10 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; +use mas_data_model::{BoxClock, BoxRng}; use mas_keystore::Encrypter; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng, oauth2::OAuth2DeviceCodeGrantParams}; +use mas_storage::{BoxRepository, oauth2::OAuth2DeviceCodeGrantParams}; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, requests::{DeviceAuthorizationRequest, DeviceAuthorizationResponse, GrantType}, diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 05e1d502d..30a35aa17 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context; use axum::{ @@ -16,9 +16,10 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; +use mas_data_model::{BoxClock, BoxRng}; use mas_policy::Policy; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::BoxRepository; use mas_templates::{DeviceConsentContext, PolicyViolationContext, TemplateContext, Templates}; use serde::Deserialize; use tracing::warn; diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index 0e3c8bd2c..84d0c5077 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -1,16 +1,18 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Query, State}, + extract::State, response::{Html, IntoResponse}, }; +use axum_extra::extract::Query; use mas_axum_utils::{InternalError, cookies::CookieJar}; +use mas_data_model::BoxClock; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository}; +use mas_storage::BoxRepository; use mas_templates::{ DeviceLinkContext, DeviceLinkFormField, FieldError, FormState, TemplateContext, Templates, }; diff --git a/crates/handlers/src/oauth2/device/mod.rs b/crates/handlers/src/oauth2/device/mod.rs index d874cf19b..565ce5df5 100644 --- a/crates/handlers/src/oauth2/device/mod.rs +++ b/crates/handlers/src/oauth2/device/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub mod authorize; pub mod consent; diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index bdefa3e62..61dfc1ba5 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{Json, extract::State, response::IntoResponse}; use mas_iana::oauth::{ @@ -35,7 +35,6 @@ struct DiscoveryResponse { } #[tracing::instrument(name = "handlers.oauth2.discovery.get", skip_all)] -#[allow(clippy::too_many_lines)] pub(crate) async fn get( State(key_store): State, State(url_builder): State, diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 3ae4a3a1c..17f508921 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -1,10 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -use std::sync::LazyLock; +use std::{ + collections::BTreeSet, + sync::{Arc, LazyLock}, +}; use axum::{Json, extract::State, http::HeaderValue, response::IntoResponse}; use hyper::{HeaderMap, StatusCode}; @@ -12,11 +15,14 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; -use mas_data_model::{Device, TokenFormatError, TokenType}; +use mas_data_model::{ + BoxClock, Clock, Device, TokenFormatError, TokenType, personal::session::PersonalSessionOwner, +}; use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint}; use mas_keystore::Encrypter; +use mas_matrix::HomeserverConnection; use mas_storage::{ - BoxClock, BoxRepository, Clock, + BoxRepository, compat::{CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository}, oauth2::{OAuth2AccessTokenRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository}, user::UserRepository, @@ -24,7 +30,7 @@ use mas_storage::{ use oauth2_types::{ errors::{ClientError, ClientErrorCode}, requests::{IntrospectionRequest, IntrospectionResponse}, - scope::ScopeToken, + scope::{Scope, ScopeToken}, }; use opentelemetry::{Key, KeyValue, metrics::Counter}; use thiserror::Error; @@ -89,6 +95,14 @@ pub enum RouteError { #[error("unknown compat session {0}")] CantLoadCompatSession(Ulid), + /// The personal access token session is not valid. + #[error("invalid personal access token session {0}")] + InvalidPersonalSession(Ulid), + + /// The personal access token session could not be found in the database. + #[error("unknown personal access token session {0}")] + CantLoadPersonalSession(Ulid), + /// The Device ID in the compat session can't be encoded as a scope #[error("device ID contains characters that are not allowed in a scope")] CantEncodeDeviceID(#[from] mas_data_model::ToScopeTokenError), @@ -99,11 +113,20 @@ pub enum RouteError { #[error("unknown user {0}")] CantLoadUser(Ulid), + #[error("unknown OAuth2 client {0}")] + CantLoadOAuth2Client(Ulid), + #[error("bad request")] BadRequest, + #[error("failed to verify token")] + FailedToVerifyToken(#[source] anyhow::Error), + #[error(transparent)] ClientCredentialsVerification(#[from] CredentialsVerificationError), + + #[error("bearer token presented is invalid")] + InvalidBearerToken, } impl IntoResponse for RouteError { @@ -114,13 +137,17 @@ impl IntoResponse for RouteError { | Self::CantLoadCompatSession(_) | Self::CantLoadOAuthSession(_) | Self::CantLoadUser(_) + | Self::FailedToVerifyToken(_) ); let response = match self { e @ (Self::Internal(_) | Self::CantLoadCompatSession(_) | Self::CantLoadOAuthSession(_) - | Self::CantLoadUser(_)) => ( + | Self::CantLoadPersonalSession(_) + | Self::CantLoadUser(_) + | Self::CantLoadOAuth2Client(_) + | Self::FailedToVerifyToken(_)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json( ClientError::from(ClientErrorCode::ServerError).with_description(e.to_string()), @@ -140,6 +167,14 @@ impl IntoResponse for RouteError { ), ) .into_response(), + e @ Self::InvalidBearerToken => ( + StatusCode::UNAUTHORIZED, + Json( + ClientError::from(ClientErrorCode::AccessDenied) + .with_description(e.to_string()), + ), + ) + .into_response(), Self::UnknownToken(_) | Self::UnexpectedTokenType @@ -147,6 +182,7 @@ impl IntoResponse for RouteError { | Self::InvalidUser(_) | Self::InvalidCompatSession(_) | Self::InvalidOAuthSession(_) + | Self::InvalidPersonalSession(_) | Self::InvalidTokenFormat(_) | Self::CantEncodeDeviceID(_) => { INTROSPECTION_COUNTER.add(1, &[KeyValue::new(ACTIVE.clone(), false)]); @@ -190,52 +226,88 @@ const INACTIVE: IntrospectionResponse = IntrospectionResponse { device_id: None, }; -const API_SCOPE: ScopeToken = ScopeToken::from_static("urn:matrix:org.matrix.msc2967.client:api:*"); +const UNSTABLE_API_SCOPE: ScopeToken = + ScopeToken::from_static("urn:matrix:org.matrix.msc2967.client:api:*"); +const STABLE_API_SCOPE: ScopeToken = ScopeToken::from_static("urn:matrix:client:api:*"); const SYNAPSE_ADMIN_SCOPE: ScopeToken = ScopeToken::from_static("urn:synapse:admin:*"); +/// Normalize a scope by adding the stable and unstable API scopes equivalents +/// if missing +fn normalize_scope(mut scope: Scope) -> Scope { + // Here we abuse the fact that the scope is a BTreeSet to not care about + // duplicates + let mut to_add = BTreeSet::new(); + for token in &*scope { + if token == &STABLE_API_SCOPE { + to_add.insert(UNSTABLE_API_SCOPE); + } else if token == &UNSTABLE_API_SCOPE { + to_add.insert(STABLE_API_SCOPE); + } else if let Some(device) = Device::from_scope_token(token) { + let tokens = device + .to_scope_token() + .expect("from/to scope token rountrip should never fail"); + to_add.extend(tokens); + } + } + scope.append(&mut to_add); + scope +} + #[tracing::instrument( name = "handlers.oauth2.introspection.post", - fields(client.id = client_authorization.client_id()), + fields(client.id = credentials.client_id()), skip_all, )] -#[allow(clippy::too_many_lines)] pub(crate) async fn post( clock: BoxClock, State(http_client): State, mut repo: BoxRepository, activity_tracker: ActivityTracker, State(encrypter): State, + State(homeserver): State>, headers: HeaderMap, - client_authorization: ClientAuthorization, + ClientAuthorization { credentials, form }: ClientAuthorization, ) -> Result { - let client = client_authorization - .credentials - .fetch(&mut repo) - .await? - .ok_or(RouteError::ClientNotFound)?; - - let method = match &client.token_endpoint_auth_method { - None | Some(OAuthClientAuthenticationMethod::None) => { - return Err(RouteError::NotAllowed(client.id)); + if let Some(token) = credentials.bearer_token() { + // If the client presented a bearer token, we check with the homeserver + // configuration if it is allowed to use the introspection endpoint + if !homeserver + .verify_token(token) + .await + .map_err(RouteError::FailedToVerifyToken)? + { + return Err(RouteError::InvalidBearerToken); } - Some(c) => c, - }; + } else { + // Otherwise, it presented regular client credentials, so we verify them + let client = credentials + .fetch(&mut repo) + .await? + .ok_or(RouteError::ClientNotFound)?; - client_authorization - .credentials - .verify(&http_client, &encrypter, method, &client) - .await?; + // Only confidential clients are allowed to introspect + let method = match &client.token_endpoint_auth_method { + None | Some(OAuthClientAuthenticationMethod::None) => { + return Err(RouteError::NotAllowed(client.id)); + } + Some(c) => c, + }; - let Some(form) = client_authorization.form else { + credentials + .verify(&http_client, &encrypter, method, &client) + .await?; + } + + let Some(form) = form else { return Err(RouteError::BadRequest); }; let token = &form.token; let token_type = TokenType::check(token)?; - if let Some(hint) = form.token_type_hint { - if token_type != hint { - return Err(RouteError::UnexpectedTokenType); - } + if let Some(hint) = form.token_type_hint + && token_type != hint + { + return Err(RouteError::UnexpectedTokenType); } // Not all device IDs can be encoded as scope. On OAuth 2.0 sessions, we @@ -311,9 +383,11 @@ pub(crate) async fn post( ], ); + let scope = normalize_scope(session.scope); + IntrospectionResponse { active: true, - scope: Some(session.scope), + scope: Some(scope), client_id: Some(session.client_id.to_string()), username, token_type: Some(OAuthTokenTypeHint::AccessToken), @@ -382,9 +456,11 @@ pub(crate) async fn post( ], ); + let scope = normalize_scope(session.scope); + IntrospectionResponse { active: true, - scope: Some(session.scope), + scope: Some(scope), client_id: Some(session.client_id.to_string()), username, token_type: Some(OAuthTokenTypeHint::RefreshToken), @@ -446,9 +522,9 @@ pub(crate) async fn post( .transpose()? }; - let scope = [API_SCOPE] + let scope = [STABLE_API_SCOPE, UNSTABLE_API_SCOPE] .into_iter() - .chain(device_scope_opt) + .chain(device_scope_opt.into_iter().flatten()) .chain(synapse_admin_scope_opt) .collect(); @@ -530,9 +606,9 @@ pub(crate) async fn post( .transpose()? }; - let scope = [API_SCOPE] + let scope = [STABLE_API_SCOPE, UNSTABLE_API_SCOPE] .into_iter() - .chain(device_scope_opt) + .chain(device_scope_opt.into_iter().flatten()) .chain(synapse_admin_scope_opt) .collect(); @@ -565,6 +641,97 @@ pub(crate) async fn post( device_id: session.device.map(Device::into), } } + + TokenType::PersonalAccessToken => { + let access_token = repo + .personal_access_token() + .find_by_token(token) + .await? + .ok_or(RouteError::UnknownToken(TokenType::AccessToken))?; + + if !access_token.is_valid(clock.now()) { + return Err(RouteError::InvalidToken(TokenType::AccessToken)); + } + + let session = repo + .personal_session() + .lookup(access_token.session_id) + .await? + .ok_or(RouteError::CantLoadPersonalSession(access_token.session_id))?; + + if !session.is_valid() { + return Err(RouteError::InvalidPersonalSession(session.id)); + } + + let actor_user = repo + .user() + .lookup(session.actor_user_id) + .await? + .ok_or(RouteError::CantLoadUser(session.actor_user_id))?; + + if !actor_user.is_valid() { + return Err(RouteError::InvalidUser(actor_user.id)); + } + + let client_id = match session.owner { + PersonalSessionOwner::User(owner_user_id) => { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or(RouteError::CantLoadUser(owner_user_id))?; + + if !owner_user.is_valid() { + return Err(RouteError::InvalidUser(owner_user.id)); + } + + None + } + PersonalSessionOwner::OAuth2Client(owner_client_id) => { + let owner_client = repo + .oauth2_client() + .lookup(owner_client_id) + .await? + .ok_or(RouteError::CantLoadOAuth2Client(owner_client_id))?; + + // OAuth2 clients are always valid if they're in the database + Some(owner_client.client_id.clone()) + } + }; + + activity_tracker + .record_personal_session(&clock, &session, ip) + .await; + + INTROSPECTION_COUNTER.add( + 1, + &[ + KeyValue::new(KIND, "personal_access_token"), + KeyValue::new(ACTIVE, true), + ], + ); + + let scope = normalize_scope(session.scope); + + IntrospectionResponse { + active: true, + scope: Some(scope), + client_id, + username: Some(actor_user.username), + token_type: Some(OAuthTokenTypeHint::AccessToken), + exp: access_token.expires_at, + expires_in: access_token + .expires_at + .map(|expires_at| expires_at.signed_duration_since(clock.now())), + iat: Some(access_token.created_at), + nbf: Some(access_token.created_at), + sub: Some(actor_user.sub), + aud: None, + iss: None, + jti: None, + device_id: None, + } + } }; repo.save().await?; @@ -576,12 +743,14 @@ pub(crate) async fn post( mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_data_model::{AccessToken, RefreshToken}; + use mas_data_model::{ + AccessToken, Clock, RefreshToken, TokenType, personal::session::PersonalSessionOwner, + }; use mas_iana::oauth::OAuthTokenTypeHint; - use mas_matrix::{HomeserverConnection, ProvisionRequest}; + use mas_matrix::{HomeserverConnection, MockHomeserverConnection, ProvisionRequest}; use mas_router::{OAuth2Introspection, OAuth2RegistrationEndpoint, SimpleRoute}; - use mas_storage::Clock; use oauth2_types::{ + errors::{ClientError, ClientErrorCode}, registration::ClientRegistrationResponse, requests::IntrospectionResponse, scope::{OPENID, Scope}, @@ -634,10 +803,9 @@ mod tests { .await .unwrap(); - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); @@ -835,10 +1003,9 @@ mod tests { .await .unwrap(); - let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection - .provision_user(&ProvisionRequest::new(mxid, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) .await .unwrap(); @@ -879,7 +1046,7 @@ mod tests { let refresh_token = response["refresh_token"].as_str().unwrap(); let device_id = response["device_id"].as_str().unwrap(); let expected_scope: Scope = - format!("urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:{device_id}") + format!("urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:{device_id} urn:matrix:client:api:* urn:matrix:client:device:{device_id}") .parse() .unwrap(); @@ -912,7 +1079,7 @@ mod tests { assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken)); assert_eq!( response.scope.map(|s| s.to_string()), - Some("urn:matrix:org.matrix.msc2967.client:api:*".to_owned()) + Some("urn:matrix:client:api:* urn:matrix:org.matrix.msc2967.client:api:*".to_owned()) ); assert_eq!(response.device_id.as_deref(), Some(device_id)); @@ -986,4 +1153,150 @@ mod tests { let response: IntrospectionResponse = response.json(); assert!(response.active); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_introspect_with_bearer_token(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Check that talking to the introspection endpoint with the bearer token from + // the MockHomeserverConnection doens't error out + let request = Request::post(OAuth2Introspection::PATH) + .bearer(MockHomeserverConnection::VALID_BEARER_TOKEN) + .form(json!({ "token": "some_token" })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(!response.active); + + // Check with another token, we should get a 401 + let request = Request::post(OAuth2Introspection::PATH) + .bearer("another_token") + .form(json!({ "token": "some_token" })); + let response = state.request(request).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let response: ClientError = response.json(); + assert_eq!(response.error, ClientErrorCode::AccessDenied); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_introspect_personal_access_tokens(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Provision a client which will be used to do introspection requests + let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({ + "client_uri": "https://introspecting.com/", + "grant_types": [], + "token_endpoint_auth_method": "client_secret_basic", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let client: ClientRegistrationResponse = response.json(); + let introspecting_client_id = client.client_id; + let introspecting_client_secret = client.client_secret.unwrap(); + + let mut repo = state.repository().await.unwrap(); + + // Provision an owner user (who provisions the personal session) + let owner_user = repo + .user() + .add(&mut state.rng(), &state.clock, "admin".to_owned()) + .await + .unwrap(); + + // Provision an actor user (which the token represents) + let actor_user = repo + .user() + .add(&mut state.rng(), &state.clock, "bruce".to_owned()) + .await + .unwrap(); + + // admin creates a personal session to control bruce's account + let personal_session = repo + .personal_session() + .add( + &mut state.rng(), + &state.clock, + PersonalSessionOwner::User(owner_user.id), + &actor_user, + "Test Personal Access Token".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + + // Generate a personal access token with proper token format + let token_string = TokenType::PersonalAccessToken.generate(&mut state.rng()); + let _personal_access_token = repo + .personal_access_token() + .add( + &mut state.rng(), + &state.clock, + &personal_session, + &token_string, + Some(Duration::try_hours(1).unwrap()), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Now that we have a personal access token, we can introspect it + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({ "token": token_string })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(response.active); + // Actor user + assert_eq!(response.username, Some("bruce".to_owned())); + // Not owned by a client + assert_eq!(response.client_id, None); + assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken)); + assert_eq!(response.scope, Some(Scope::from_iter([OPENID]))); + + // Do the same request, but with a token_type_hint + let last_active = state.clock.now(); + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({"token": token_string, "token_type_hint": "access_token"})); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(response.active); + + // Do the same request, but with the wrong token_type_hint + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({"token": token_string, "token_type_hint": "refresh_token"})); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(!response.active); // It shouldn't be active with wrong hint + + // Advance the clock to invalidate the access token + state.clock.advance(Duration::try_hours(2).unwrap()); + + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({ "token": token_string })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(!response.active); // It shouldn't be active anymore + + state.activity_tracker.flush().await; + let mut repo = state.repository().await.unwrap(); + let session = repo + .personal_session() + .lookup(personal_session.id) + .await + .unwrap() + .unwrap(); + assert_eq!(session.last_active_at, Some(last_active)); + repo.save().await.unwrap(); + } } diff --git a/crates/handlers/src/oauth2/keys.rs b/crates/handlers/src/oauth2/keys.rs index bcb419969..04e4135e3 100644 --- a/crates/handlers/src/oauth2/keys.rs +++ b/crates/handlers/src/oauth2/keys.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{Json, extract::State, response::IntoResponse}; use mas_keystore::Keystore; diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index f15a1ae9d..cf28818e2 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; use chrono::Duration; use mas_data_model::{ - AccessToken, Authentication, AuthorizationGrant, BrowserSession, Client, RefreshToken, Session, - TokenType, + AccessToken, Authentication, AuthorizationGrant, BrowserSession, Client, Clock, RefreshToken, + Session, TokenType, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_jose::{ @@ -19,7 +19,7 @@ use mas_jose::{ }; use mas_keystore::Keystore; use mas_router::UrlBuilder; -use mas_storage::{Clock, RepositoryAccess}; +use mas_storage::RepositoryAccess; use thiserror::Error; pub mod authorization; diff --git a/crates/handlers/src/oauth2/registration.rs b/crates/handlers/src/oauth2/registration.rs index f3f91d754..5ad58b386 100644 --- a/crates/handlers/src/oauth2/registration.rs +++ b/crates/handlers/src/oauth2/registration.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; @@ -10,10 +10,11 @@ use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::TypedHeader; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_data_model::{BoxClock, BoxRng}; use mas_iana::oauth::OAuthClientAuthenticationMethod; use mas_keystore::Encrypter; use mas_policy::{EvaluationResult, Policy}; -use mas_storage::{BoxClock, BoxRepository, BoxRng, oauth2::OAuth2ClientRepository}; +use mas_storage::{BoxRepository, oauth2::OAuth2ClientRepository}; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, registration::{ @@ -241,34 +242,34 @@ pub(crate) async fn post( // Some extra validation that is hard to do in OPA and not done by the // `validate` method either - if let Some(client_uri) = &metadata.client_uri { - if localised_url_has_public_suffix(client_uri) { - return Err(RouteError::UrlIsPublicSuffix("client_uri")); - } + if let Some(client_uri) = &metadata.client_uri + && localised_url_has_public_suffix(client_uri) + { + return Err(RouteError::UrlIsPublicSuffix("client_uri")); } - if let Some(logo_uri) = &metadata.logo_uri { - if localised_url_has_public_suffix(logo_uri) { - return Err(RouteError::UrlIsPublicSuffix("logo_uri")); - } + if let Some(logo_uri) = &metadata.logo_uri + && localised_url_has_public_suffix(logo_uri) + { + return Err(RouteError::UrlIsPublicSuffix("logo_uri")); } - if let Some(policy_uri) = &metadata.policy_uri { - if localised_url_has_public_suffix(policy_uri) { - return Err(RouteError::UrlIsPublicSuffix("policy_uri")); - } + if let Some(policy_uri) = &metadata.policy_uri + && localised_url_has_public_suffix(policy_uri) + { + return Err(RouteError::UrlIsPublicSuffix("policy_uri")); } - if let Some(tos_uri) = &metadata.tos_uri { - if localised_url_has_public_suffix(tos_uri) { - return Err(RouteError::UrlIsPublicSuffix("tos_uri")); - } + if let Some(tos_uri) = &metadata.tos_uri + && localised_url_has_public_suffix(tos_uri) + { + return Err(RouteError::UrlIsPublicSuffix("tos_uri")); } - if let Some(initiate_login_uri) = &metadata.initiate_login_uri { - if host_is_public_suffix(initiate_login_uri) { - return Err(RouteError::UrlIsPublicSuffix("initiate_login_uri")); - } + if let Some(initiate_login_uri) = &metadata.initiate_login_uri + && host_is_public_suffix(initiate_login_uri) + { + return Err(RouteError::UrlIsPublicSuffix("initiate_login_uri")); } for redirect_uri in metadata.redirect_uris() { diff --git a/crates/handlers/src/oauth2/revoke.rs b/crates/handlers/src/oauth2/revoke.rs index 758f0d647..fa11216dc 100644 --- a/crates/handlers/src/oauth2/revoke.rs +++ b/crates/handlers/src/oauth2/revoke.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; @@ -10,11 +10,11 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; -use mas_data_model::TokenType; +use mas_data_model::{BoxClock, BoxRng, TokenType}; use mas_iana::oauth::OAuthTokenTypeHint; use mas_keystore::Encrypter; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, RepositoryAccess, + BoxRepository, RepositoryAccess, queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, }; use oauth2_types::{ diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 3c8c9db20..4a63d8290 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock}; @@ -16,7 +16,8 @@ use mas_axum_utils::{ record_error, }; use mas_data_model::{ - AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, SiteConfig, TokenType, + AuthorizationGrantStage, BoxClock, BoxRng, Client, Clock, Device, DeviceCodeGrantState, + SiteConfig, TokenType, }; use mas_i18n::DataLocale; use mas_keystore::{Encrypter, Keystore}; @@ -25,7 +26,7 @@ use mas_oidc_client::types::scope::ScopeToken; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, + BoxRepository, RepositoryAccess, oauth2::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, @@ -409,7 +410,6 @@ pub(crate) async fn post( Ok((headers, Json(reply))) } -#[allow(clippy::too_many_lines)] // TODO: refactor some parts out async fn authorization_code_grant( mut rng: &mut BoxRng, clock: &impl Clock, @@ -575,11 +575,14 @@ async fn authorization_code_grant( .await?; // Look for device to provision - let mxid = homeserver.mxid(&browser_session.user.username); for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str(), Some(&device_name)) + .upsert_device( + &browser_session.user.username, + device.as_str(), + Some(&device_name), + ) .await .map_err(RouteError::ProvisionDeviceFailed)?; } @@ -599,7 +602,6 @@ async fn authorization_code_grant( Ok((params, repo)) } -#[allow(clippy::too_many_lines)] async fn refresh_token_grant( rng: &mut BoxRng, clock: &impl Clock, @@ -951,11 +953,10 @@ async fn device_code_grant( .await?; // Look for device to provision - let mxid = homeserver.mxid(&browser_session.user.username); for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str(), None) + .upsert_device(&browser_session.user.username, device.as_str(), None) .await .map_err(RouteError::ProvisionDeviceFailed)?; } diff --git a/crates/handlers/src/oauth2/userinfo.rs b/crates/handlers/src/oauth2/userinfo.rs index 064196e3d..c939ba9c8 100644 --- a/crates/handlers/src/oauth2/userinfo.rs +++ b/crates/handlers/src/oauth2/userinfo.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ Json, @@ -15,13 +15,14 @@ use mas_axum_utils::{ record_error, user_authorization::{AuthorizationVerificationError, UserAuthorization}, }; +use mas_data_model::{BoxClock, BoxRng}; use mas_jose::{ constraints::Constrainable, jwt::{JsonWebSignatureHeader, Jwt}, }; use mas_keystore::Keystore; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng, oauth2::OAuth2ClientRepository}; +use mas_storage::{BoxRepository, oauth2::OAuth2ClientRepository}; use serde::Serialize; use serde_with::skip_serializing_none; use thiserror::Error; diff --git a/crates/handlers/src/oauth2/webfinger.rs b/crates/handlers/src/oauth2/webfinger.rs index 5c4b3c96b..489a8e9ef 100644 --- a/crates/handlers/src/oauth2/webfinger.rs +++ b/crates/handlers/src/oauth2/webfinger.rs @@ -1,15 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -use axum::{ - Json, - extract::{Query, State}, - response::IntoResponse, -}; -use axum_extra::typed_header::TypedHeader; +use axum::{Json, extract::State, response::IntoResponse}; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use headers::ContentType; use mas_router::UrlBuilder; use oauth2_types::webfinger::WebFingerResponse; diff --git a/crates/handlers/src/passwords.rs b/crates/handlers/src/passwords.rs index f4ce10adb..6071cf730 100644 --- a/crates/handlers/src/passwords.rs +++ b/crates/handlers/src/passwords.rs @@ -1,15 +1,15 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, sync::Arc}; use anyhow::Context; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; use futures_util::future::OptionFuture; -use pbkdf2::Pbkdf2; +use pbkdf2::{Pbkdf2, password_hash}; use rand::{CryptoRng, RngCore, SeedableRng, distributions::Standard, prelude::Distribution}; use thiserror::Error; use zeroize::Zeroizing; @@ -17,6 +17,55 @@ use zxcvbn::zxcvbn; pub type SchemeVersion = u16; +/// The result of a password verification, which is `true` if the password +/// matches the hashed password, and `false` otherwise. +/// +/// In the success case it can also contain additional data, such as the new +/// hashing scheme and the new hashed password. +#[must_use] +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum PasswordVerificationResult { + /// The password matches the stored password hash + Success(T), + /// The password does not match the stored password hash + Failure, +} + +impl PasswordVerificationResult<()> { + fn success() -> Self { + Self::Success(()) + } + + fn failure() -> Self { + Self::Failure + } +} + +impl PasswordVerificationResult { + /// Converts the result into a new result with the given data. + fn with_data(self, data: N) -> PasswordVerificationResult { + match self { + Self::Success(_) => PasswordVerificationResult::Success(data), + Self::Failure => PasswordVerificationResult::Failure, + } + } + + #[must_use] + pub fn is_success(&self) -> bool { + matches!(self, Self::Success(_)) + } +} + +impl From for PasswordVerificationResult<()> { + fn from(value: bool) -> Self { + if value { + Self::success() + } else { + Self::failure() + } + } +} + #[derive(Debug, Error)] #[error("Password manager is disabled")] pub struct PasswordManagerDisabledError; @@ -149,11 +198,11 @@ impl PasswordManager { scheme: SchemeVersion, password: Zeroizing, hashed_password: String, - ) -> Result<(), anyhow::Error> { + ) -> Result { let inner = self.get_inner()?; let span = tracing::Span::current(); - tokio::task::spawn_blocking(move || { + let result = tokio::task::spawn_blocking(move || { span.in_scope(move || { let hasher = if scheme == inner.current_version { &inner.current_hasher @@ -169,7 +218,7 @@ impl PasswordManager { }) .await??; - Ok(()) + Ok(result) } /// Verify a password hash for the given hashing scheme, and upgrade it on @@ -186,7 +235,7 @@ impl PasswordManager { scheme: SchemeVersion, password: Zeroizing, hashed_password: String, - ) -> Result, anyhow::Error> { + ) -> Result>, anyhow::Error> { let inner = self.get_inner()?; // If the current scheme isn't the default one, we also hash with the default @@ -198,11 +247,11 @@ impl PasswordManager { let verify_fut = self.verify(scheme, password, hashed_password); let (new_hash_res, verify_res) = tokio::join!(new_hash_fut, verify_fut); - verify_res?; + let password_result = verify_res?; let new_hash = new_hash_res.transpose()?; - Ok(new_hash) + Ok(password_result.with_data(new_hash)) } } @@ -276,7 +325,7 @@ impl Hasher { &self, hashed_password: &str, password: Zeroizing, - ) -> Result<(), anyhow::Error> { + ) -> Result { let password = self.normalize_password(password); self.algorithm @@ -345,8 +394,8 @@ impl Algorithm { hashed_password: &str, password: &[u8], pepper: Option<&[u8]>, - ) -> Result<(), anyhow::Error> { - match self { + ) -> Result { + let result = match self { Algorithm::Bcrypt { .. } => { let mut password = Zeroizing::new(password.to_vec()); if let Some(pepper) = pepper { @@ -354,7 +403,7 @@ impl Algorithm { } let result = bcrypt::verify(password, hashed_password)?; - anyhow::ensure!(result, "wrong password"); + PasswordVerificationResult::from(result) } Algorithm::Argon2id => { @@ -370,7 +419,11 @@ impl Algorithm { let hashed_password = PasswordHash::new(hashed_password)?; - phf.verify_password(password.as_ref(), &hashed_password)?; + match phf.verify_password(password.as_ref(), &hashed_password) { + Ok(()) => PasswordVerificationResult::success(), + Err(password_hash::Error::Password) => PasswordVerificationResult::failure(), + Err(e) => Err(e)?, + } } Algorithm::Pbkdf2 => { @@ -381,11 +434,15 @@ impl Algorithm { let hashed_password = PasswordHash::new(hashed_password)?; - Pbkdf2.verify_password(password.as_ref(), &hashed_password)?; + match Pbkdf2.verify_password(password.as_ref(), &hashed_password) { + Ok(()) => PasswordVerificationResult::success(), + Err(password_hash::Error::Password) => PasswordVerificationResult::failure(), + Err(e) => Err(e)?, + } } - } + }; - Ok(()) + Ok(result) } } @@ -410,10 +467,26 @@ mod tests { .expect("Couldn't hash password"); insta::assert_snapshot!(hash); - assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok()); - assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err()); - assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err()); - assert!(alg.verify_blocking(&hash, password, None).is_err()); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Success(()) + ); + assert_eq!( + alg.verify_blocking(&hash, password2, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper2)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, None) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); // Hash without pepper let hash = alg @@ -421,9 +494,21 @@ mod tests { .expect("Couldn't hash password"); insta::assert_snapshot!(hash); - assert!(alg.verify_blocking(&hash, password, None).is_ok()); - assert!(alg.verify_blocking(&hash, password2, None).is_err()); - assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err()); + assert_eq!( + alg.verify_blocking(&hash, password, None) + .expect("Verification failed"), + PasswordVerificationResult::Success(()) + ); + assert_eq!( + alg.verify_blocking(&hash, password2, None) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); } #[test] @@ -441,10 +526,26 @@ mod tests { .expect("Couldn't hash password"); insta::assert_snapshot!(hash); - assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok()); - assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err()); - assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err()); - assert!(alg.verify_blocking(&hash, password, None).is_err()); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Success(()) + ); + assert_eq!( + alg.verify_blocking(&hash, password2, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper2)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, None) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); // Hash without pepper let hash = alg @@ -452,9 +553,21 @@ mod tests { .expect("Couldn't hash password"); insta::assert_snapshot!(hash); - assert!(alg.verify_blocking(&hash, password, None).is_ok()); - assert!(alg.verify_blocking(&hash, password2, None).is_err()); - assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err()); + assert_eq!( + alg.verify_blocking(&hash, password, None) + .expect("Verification failed"), + PasswordVerificationResult::Success(()) + ); + assert_eq!( + alg.verify_blocking(&hash, password2, None) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); } #[test] @@ -473,10 +586,26 @@ mod tests { .expect("Couldn't hash password"); insta::assert_snapshot!(hash); - assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok()); - assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err()); - assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err()); - assert!(alg.verify_blocking(&hash, password, None).is_err()); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Success(()) + ); + assert_eq!( + alg.verify_blocking(&hash, password2, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper2)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, None) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); // Hash without pepper let hash = alg @@ -484,12 +613,23 @@ mod tests { .expect("Couldn't hash password"); insta::assert_snapshot!(hash); - assert!(alg.verify_blocking(&hash, password, None).is_ok()); - assert!(alg.verify_blocking(&hash, password2, None).is_err()); - assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err()); + assert_eq!( + alg.verify_blocking(&hash, password, None) + .expect("Verification failed"), + PasswordVerificationResult::Success(()) + ); + assert_eq!( + alg.verify_blocking(&hash, password2, None) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); + assert_eq!( + alg.verify_blocking(&hash, password, Some(pepper)) + .expect("Verification failed"), + PasswordVerificationResult::Failure + ); } - #[allow(clippy::too_many_lines)] #[tokio::test] async fn hash_verify_and_upgrade() { // Tests the whole password manager, by hashing a password and upgrading it @@ -520,16 +660,18 @@ mod tests { insta::assert_snapshot!(hash); // Just verifying works - manager + let res = manager .verify(version, password.clone(), hash.clone()) .await .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Success(())); // And doesn't work with the wrong password - manager + let res = manager .verify(version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); // Verifying with the wrong version doesn't work manager @@ -543,13 +685,14 @@ mod tests { .await .expect("Failed to verify"); - assert!(res.is_none()); + assert_eq!(res, PasswordVerificationResult::Success(None)); // Upgrading still verify that the password matches - manager + let res = manager .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); let manager = PasswordManager::new( 0, @@ -564,16 +707,18 @@ mod tests { .unwrap(); // Verifying still works - manager + let res = manager .verify(version, password.clone(), hash.clone()) .await .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Success(())); // And doesn't work with the wrong password - manager + let res = manager .verify(version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); // Upgrading does re-hash let res = manager @@ -581,9 +726,9 @@ mod tests { .await .expect("Failed to verify"); - assert!(res.is_some()); - let (version, hash) = res.unwrap(); - + let PasswordVerificationResult::Success(Some((version, hash))) = res else { + panic!("Expected a successful upgrade"); + }; assert_eq!(version, 2); insta::assert_snapshot!(hash); @@ -593,19 +738,21 @@ mod tests { .await .expect("Failed to verify"); - assert!(res.is_none()); + assert_eq!(res, PasswordVerificationResult::Success(None)); // Upgrading still verify that the password matches - manager + let res = manager .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); // Upgrading still verify that the password matches - manager + let res = manager .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); let manager = PasswordManager::new( 0, @@ -624,16 +771,18 @@ mod tests { .unwrap(); // Verifying still works - manager + let res = manager .verify(version, password.clone(), hash.clone()) .await .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Success(())); // And doesn't work with the wrong password - manager + let res = manager .verify(version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); // Upgrading does re-hash let res = manager @@ -641,8 +790,9 @@ mod tests { .await .expect("Failed to verify"); - assert!(res.is_some()); - let (version, hash) = res.unwrap(); + let PasswordVerificationResult::Success(Some((version, hash))) = res else { + panic!("Expected a successful upgrade"); + }; assert_eq!(version, 3); insta::assert_snapshot!(hash); @@ -653,12 +803,13 @@ mod tests { .await .expect("Failed to verify"); - assert!(res.is_none()); + assert_eq!(res, PasswordVerificationResult::Success(None)); // Upgrading still verify that the password matches - manager + let res = manager .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone()) .await - .expect_err("Verification should have failed"); + .expect("Failed to verify"); + assert_eq!(res, PasswordVerificationResult::Failure); } } diff --git a/crates/handlers/src/preferred_language.rs b/crates/handlers/src/preferred_language.rs index cfa0e106d..8ea38c990 100644 --- a/crates/handlers/src/preferred_language.rs +++ b/crates/handlers/src/preferred_language.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{convert::Infallible, sync::Arc}; diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index bb5642036..0471e6351 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{net::IpAddr, sync::Arc, time::Duration}; @@ -301,8 +301,7 @@ impl Limiter { #[cfg(test)] mod tests { - use mas_data_model::User; - use mas_storage::{Clock, clock::MockClock}; + use mas_data_model::{Clock, User, clock::MockClock}; use rand::SeedableRng; use super::*; @@ -329,6 +328,7 @@ mod tests { locked_at: None, deactivated_at: None, can_request_admin: false, + is_guest: true, }; let bob = User { @@ -339,6 +339,7 @@ mod tests { locked_at: None, deactivated_at: None, can_request_admin: false, + is_guest: true, }; // Three times the same IP address should be allowed diff --git a/crates/handlers/src/session.rs b/crates/handlers/src/session.rs index 9eac19307..cb05510ba 100644 --- a/crates/handlers/src/session.rs +++ b/crates/handlers/src/session.rs @@ -1,16 +1,16 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Utilities for showing proposer HTML fallbacks when the user is logged out, //! locked or deactivated use axum::response::{Html, IntoResponse as _, Response}; use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt}; -use mas_data_model::BrowserSession; +use mas_data_model::{BrowserSession, Clock}; use mas_i18n::DataLocale; -use mas_storage::{BoxRepository, Clock, RepositoryError}; +use mas_storage::{BoxRepository, RepositoryError}; use mas_templates::{AccountInactiveContext, TemplateContext, Templates}; use rand::RngCore; use thiserror::Error; diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index f5a7403da..f1859f352 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ convert::Infallible, @@ -28,17 +28,16 @@ use mas_axum_utils::{ cookies::{CookieJar, CookieManager}, }; use mas_config::RateLimitingConfig; -use mas_data_model::SiteConfig; +use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, clock::MockClock}; +use mas_email::{MailTransport, Mailer}; use mas_i18n::Translator; use mas_keystore::{Encrypter, JsonWebKey, JsonWebKeySet, Keystore, PrivateKey}; use mas_matrix::{HomeserverConnection, MockHomeserverConnection}; use mas_policy::{InstantiateError, Policy, PolicyFactory}; use mas_router::{SimpleRoute, UrlBuilder}; -use mas_storage::{ - BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, RepositoryError, RepositoryFactory, - clock::MockClock, -}; +use mas_storage::{BoxRepository, BoxRepositoryFactory, RepositoryError, RepositoryFactory}; use mas_storage_pg::PgRepositoryFactory; +use mas_tasks::QueueWorker; use mas_templates::{SiteConfigExt, Templates}; use oauth2_types::{registration::ClientRegistrationResponse, requests::AccessTokenResponse}; use rand::SeedableRng; @@ -113,6 +112,7 @@ pub(crate) struct TestState { pub rng: Arc>, pub http_client: reqwest::Client, pub task_tracker: TaskTracker, + queue_worker: Arc>, #[allow(dead_code)] // It is used, as it will cancel the CancellationToken when dropped cancellation_drop_guard: Arc, @@ -140,6 +140,7 @@ pub fn test_site_config() -> SiteConfig { email_change_allowed: true, displayname_change_allowed: true, password_change_allowed: true, + password_registration_email_required: true, account_recovery_allowed: true, account_deactivation_allowed: true, captcha: None, @@ -175,6 +176,8 @@ impl TestState { workspace_root.join("translations"), site_config.templates_branding(), site_config.templates_features(), + // Strict mode in testing + true, ) .await?; @@ -235,6 +238,27 @@ impl TestState { shutdown_token.child_token(), ); + let mailer = Mailer::new( + templates.clone(), + MailTransport::blackhole(), + "hello@example.com".parse().unwrap(), + "hello@example.com".parse().unwrap(), + ); + + let queue_worker = mas_tasks::init( + PgRepositoryFactory::new(pool.clone()), + Arc::clone(&clock), + &mailer, + homeserver_connection.clone(), + url_builder.clone(), + &site_config, + shutdown_token.child_token(), + ) + .await + .unwrap(); + + let queue_worker = Arc::new(tokio::sync::Mutex::new(queue_worker)); + Ok(Self { repository_factory: PgRepositoryFactory::new(pool), templates, @@ -254,10 +278,19 @@ impl TestState { rng, http_client, task_tracker, + queue_worker, cancellation_drop_guard: Arc::new(shutdown_token.drop_guard()), }) } + /// Run all the available jobs in the queue. + /// + /// Panics if it fails to run the jobs (but not on job failures!) + pub async fn run_jobs_in_queue(&self) { + let mut queue = self.queue_worker.lock().await; + queue.process_all_jobs_in_tests().await.unwrap(); + } + /// Reset the test utils to a fresh state, with the same configuration. pub async fn reset(self) -> Self { let site_config = self.site_config.clone(); @@ -286,7 +319,7 @@ impl TestState { let app = crate::healthcheck_router() .merge(crate::discovery_router()) .merge(crate::api_router()) - .merge(crate::compat_router()) + .merge(crate::compat_router(self.templates.clone())) .merge(crate::human_router(self.templates.clone())) // We enable undocumented_oauth2_access for the tests, as it is easier to query the API // with it @@ -545,6 +578,12 @@ impl FromRef for reqwest::Client { } } +impl FromRef for AppVersion { + fn from_ref(_input: &TestState) -> Self { + AppVersion("v0.0.0-test") + } +} + impl FromRequestParts for ActivityTracker { type Rejection = Infallible; diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index 43403c137..8749f3c3d 100644 --- a/crates/handlers/src/upstream_oauth2/authorize.rs +++ b/crates/handlers/src/upstream_oauth2/authorize.rs @@ -1,20 +1,21 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Path, Query, State}, + extract::{Path, State}, response::{IntoResponse, Redirect}, }; +use axum_extra::extract::Query; use hyper::StatusCode; -use mas_axum_utils::{cookies::CookieJar, record_error}; -use mas_data_model::UpstreamOAuthProvider; +use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar}; +use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider}; use mas_oidc_client::requests::authorization_code::AuthorizationRequestData; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, + BoxRepository, upstream_oauth2::{UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository}, }; use thiserror::Error; @@ -41,13 +42,12 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let sentry_event_id = record_error!(self, Self::Internal(_)); - let response = match self { - Self::ProviderNotFound => (StatusCode::NOT_FOUND, "Provider not found").into_response(), - Self::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - - (sentry_event_id, response).into_response() + match self { + e @ Self::ProviderNotFound => { + GenericError::new(StatusCode::NOT_FOUND, e).into_response() + } + Self::Internal(e) => InternalError::new(e).into_response(), + } } } @@ -94,17 +94,15 @@ pub(crate) async fn get( // Forward the raw login hint upstream for the provider to handle however it // sees fit - if provider.forward_login_hint { - if let Some(PostAuthAction::ContinueAuthorizationGrant { id }) = &query.post_auth_action { - if let Some(login_hint) = repo - .oauth2_authorization_grant() - .lookup(*id) - .await? - .and_then(|grant| grant.login_hint) - { - data = data.with_login_hint(login_hint); - } - } + if provider.forward_login_hint + && let Some(PostAuthAction::ContinueAuthorizationGrant { id }) = &query.post_auth_action + && let Some(login_hint) = repo + .oauth2_authorization_grant() + .lookup(*id) + .await? + .and_then(|grant| grant.login_hint) + { + data = data.with_login_hint(login_hint); } let data = if let Some(methods) = lazy_metadata.pkce_methods().await? { diff --git a/crates/handlers/src/upstream_oauth2/backchannel_logout.rs b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs new file mode 100644 index 000000000..63454741c --- /dev/null +++ b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs @@ -0,0 +1,318 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::collections::{HashMap, HashSet}; + +use axum::{ + Form, Json, + extract::{Path, State, rejection::FormRejection}, + response::IntoResponse, +}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::{ + BoxClock, BoxRng, UpstreamOAuthProvider, UpstreamOAuthProviderOnBackchannelLogout, +}; +use mas_jose::{ + claims::{self, Claim, TimeOptions}, + jwt::JwtDecodeError, +}; +use mas_oidc_client::{ + error::JwtVerificationError, + requests::jose::{JwtVerificationData, verify_signed_jwt}, +}; +use mas_storage::{ + BoxRepository, Pagination, + compat::CompatSessionFilter, + oauth2::OAuth2SessionFilter, + queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, + upstream_oauth2::UpstreamOAuthSessionFilter, + user::BrowserSessionFilter, +}; +use oauth2_types::errors::{ClientError, ClientErrorCode}; +use serde::Deserialize; +use serde_json::Value; +use thiserror::Error; +use ulid::Ulid; + +use crate::{MetadataCache, impl_from_error_for_route, upstream_oauth2::cache::LazyProviderInfos}; + +#[derive(Debug, Error)] +pub enum RouteError { + /// An internal error occurred. + #[error(transparent)] + Internal(Box), + + /// Invalid request body + #[error(transparent)] + InvalidRequestBody(#[from] FormRejection), + + /// Logout token is not a JWT + #[error("failed to decode logout token")] + InvalidLogoutToken(#[from] JwtDecodeError), + + /// Logout token failed to be verified + #[error("failed to verify logout token")] + LogoutTokenVerification(#[from] JwtVerificationError), + + /// Logout token had invalid claims + #[error("invalid claims in logout token")] + InvalidLogoutTokenClaims(#[from] claims::ClaimError), + + /// Logout token has neither a sub nor a sid claim + #[error("logout token has neither a sub nor a sid claim")] + NoSubOrSidClaim, + + /// Provider not found + #[error("provider not found")] + ProviderNotFound, +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let sentry_event_id = record_error!(self, Self::Internal(_)); + + let response = match self { + e @ Self::Internal(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + ClientError::from(ClientErrorCode::ServerError).with_description(e.to_string()), + ), + ) + .into_response(), + + e @ (Self::InvalidLogoutToken(_) + | Self::LogoutTokenVerification(_) + | Self::InvalidRequestBody(_) + | Self::InvalidLogoutTokenClaims(_) + | Self::NoSubOrSidClaim) => ( + StatusCode::BAD_REQUEST, + Json( + ClientError::from(ClientErrorCode::InvalidRequest) + .with_description(e.to_string()), + ), + ) + .into_response(), + + Self::ProviderNotFound => ( + StatusCode::NOT_FOUND, + Json( + ClientError::from(ClientErrorCode::InvalidRequest).with_description( + "Upstream OAuth provider not found, is the backchannel logout URI right?" + .to_owned(), + ), + ), + ) + .into_response(), + }; + + (sentry_event_id, response).into_response() + } +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(mas_oidc_client::error::DiscoveryError); +impl_from_error_for_route!(mas_oidc_client::error::JwksError); + +#[derive(Deserialize)] +pub(crate) struct BackchannelLogoutRequest { + logout_token: String, +} + +#[derive(Deserialize)] +struct LogoutTokenEvents { + #[allow(dead_code)] // We just want to check it deserializes + #[serde(rename = "http://schemas.openid.net/event/backchannel-logout")] + backchannel_logout: HashMap, +} + +const EVENTS: Claim = Claim::new("events"); + +#[tracing::instrument( + name = "handlers.upstream_oauth2.backchannel_logout.post", + fields(upstream_oauth_provider.id = %provider_id), + skip_all, +)] +pub(crate) async fn post( + clock: BoxClock, + mut rng: BoxRng, + mut repo: BoxRepository, + State(metadata_cache): State, + State(client): State, + Path(provider_id): Path, + request: Result, FormRejection>, +) -> Result { + let Form(request) = request?; + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .filter(UpstreamOAuthProvider::enabled) + .ok_or(RouteError::ProviderNotFound)?; + + let mut lazy_metadata = LazyProviderInfos::new(&metadata_cache, &provider, &client); + + let jwks = + mas_oidc_client::requests::jose::fetch_jwks(&client, lazy_metadata.jwks_uri().await?) + .await?; + + // Validate the logout token. The rules are defined in + // + // + // Upon receiving a logout request at the back-channel logout URI, the RP MUST + // validate the Logout Token as follows: + // + // 1. If the Logout Token is encrypted, decrypt it using the keys and + // algorithms that the Client specified during Registration that the OP was + // to use to encrypt ID Tokens. If ID Token encryption was negotiated with + // the OP at Registration time and the Logout Token is not encrypted, the RP + // SHOULD reject it. + // 2. Validate the Logout Token signature in the same way that an ID Token + // signature is validated, with the following refinements. + // 3. Validate the alg (algorithm) Header Parameter in the same way it is + // validated for ID Tokens. Like ID Tokens, selection of the algorithm used + // is governed by the id_token_signing_alg_values_supported Discovery + // parameter and the id_token_signed_response_alg Registration parameter + // when they are used; otherwise, the value SHOULD be the default of RS256. + // Additionally, an alg with the value none MUST NOT be used for Logout + // Tokens. + // 4. Validate the iss, aud, iat, and exp Claims in the same way they are + // validated in ID Tokens. + // 5. Verify that the Logout Token contains a sub Claim, a sid Claim, or both. + // 6. Verify that the Logout Token contains an events Claim whose value is JSON + // object containing the member name http://schemas.openid.net/event/backchannel-logout. + // 7. Verify that the Logout Token does not contain a nonce Claim. + // 8. Optionally verify that another Logout Token with the same jti value has + // not been recently received. + // 9. Optionally verify that the iss Logout Token Claim matches the iss Claim + // in an ID Token issued for the current session or a recent session of this + // RP with the OP. + // 10. Optionally verify that any sub Logout Token Claim matches the sub Claim + // in an ID Token issued for the current session or a recent session of + // this RP with the OP. + // 11. Optionally verify that any sid Logout Token Claim matches the sid Claim + // in an ID Token issued for the current session or a recent session of + // this RP with the OP. + // + // If any of the validation steps fails, reject the Logout Token and return an + // HTTP 400 Bad Request error. Otherwise, proceed to perform the logout actions. + // + // The ISS and AUD claims are already checked by the verify_signed_jwt() + // function. + + // This verifies (1), (2), (3) and the iss and aud claims for (4) + let token = verify_signed_jwt( + &request.logout_token, + JwtVerificationData { + issuer: provider.issuer.as_deref(), + jwks: &jwks, + client_id: &provider.client_id, + signing_algorithm: &provider.id_token_signed_response_alg, + }, + )?; + + let (_header, mut claims) = token.into_parts(); + + let time_options = TimeOptions::new(clock.now()); + claims::EXP.extract_required_with_options(&mut claims, &time_options)?; // (4) + claims::IAT.extract_required_with_options(&mut claims, &time_options)?; // (4) + + let sub = claims::SUB.extract_optional(&mut claims)?; // (5) + let sid = claims::SID.extract_optional(&mut claims)?; // (5) + if sub.is_none() && sid.is_none() { + return Err(RouteError::NoSubOrSidClaim); + } + + EVENTS.extract_required(&mut claims)?; // (6) + claims::NONCE.assert_absent(&claims)?; // (7) + + // Find the corresponding upstream OAuth 2.0 sessions + let mut auth_session_filter = UpstreamOAuthSessionFilter::new().for_provider(&provider); + if let Some(sub) = &sub { + auth_session_filter = auth_session_filter.with_sub_claim(sub); + } + if let Some(sid) = &sid { + auth_session_filter = auth_session_filter.with_sid_claim(sid); + } + let count = repo + .upstream_oauth_session() + .count(auth_session_filter) + .await?; + + tracing::info!(sub, sid, %provider.id, "Backchannel logout received, found {count} corresponding authentication sessions"); + + match provider.on_backchannel_logout { + UpstreamOAuthProviderOnBackchannelLogout::DoNothing => { + tracing::warn!(%provider.id, "Provider configured to do nothing on backchannel logout"); + } + UpstreamOAuthProviderOnBackchannelLogout::LogoutBrowserOnly => { + let filter = BrowserSessionFilter::new() + .authenticated_by_upstream_sessions_only(auth_session_filter) + .active_only(); + let affected = repo.browser_session().finish_bulk(&clock, filter).await?; + tracing::info!("Finished {affected} browser sessions"); + } + UpstreamOAuthProviderOnBackchannelLogout::LogoutAll => { + let browser_session_filter = BrowserSessionFilter::new() + .authenticated_by_upstream_sessions_only(auth_session_filter); + + // We need to loop through all the browser sessions to find all the + // users affected so that we can trigger a device sync job for them + let mut cursor = Pagination::first(1000); + let mut user_ids = HashSet::new(); + loop { + let browser_sessions = repo + .browser_session() + .list(browser_session_filter, cursor) + .await?; + for edge in browser_sessions.edges { + user_ids.insert(edge.node.user.id); + cursor = cursor.after(edge.cursor); + } + + if !browser_sessions.has_next_page { + break; + } + } + + let browser_sessions_affected = repo + .browser_session() + .finish_bulk(&clock, browser_session_filter.active_only()) + .await?; + + let oauth2_session_filter = OAuth2SessionFilter::new() + .active_only() + .for_browser_sessions(browser_session_filter); + + let oauth2_sessions_affected = repo + .oauth2_session() + .finish_bulk(&clock, oauth2_session_filter) + .await?; + + let compat_session_filter = CompatSessionFilter::new() + .active_only() + .for_browser_sessions(browser_session_filter); + + let compat_sessions_affected = repo + .compat_session() + .finish_bulk(&clock, compat_session_filter) + .await?; + + tracing::info!( + "Finished {browser_sessions_affected} browser sessions, {oauth2_sessions_affected} OAuth 2.0 sessions and {compat_sessions_affected} compatibility sessions" + ); + + for user_id in user_ids { + tracing::info!(user.id = %user_id, "Queueing a device sync job for user"); + let job = SyncDevicesJob::new_for_id(user_id); + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + } + } + } + + repo.save().await?; + + Ok(()) +} diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 6c1b7de63..0857bc2c1 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, sync::Arc}; @@ -165,6 +165,10 @@ impl MetadataCache { /// /// This spawns a background task that will refresh the cache at the given /// interval. + /// + /// # Errors + /// + /// Returns an error if the warm up task could not be started. #[tracing::instrument(name = "metadata_cache.warm_up_and_run", skip_all)] pub async fn warm_up_and_run( &self, @@ -237,6 +241,10 @@ impl MetadataCache { } /// Get the metadata for the given issuer. + /// + /// # Errors + /// + /// Returns an error if the metadata could not be retrieved. #[tracing::instrument(name = "metadata_cache.get", fields(%issuer), skip_all)] pub async fn get( &self, @@ -290,16 +298,14 @@ impl MetadataCache { #[cfg(test)] mod tests { - #![allow(clippy::too_many_lines)] - // XXX: sadly, we can't test HTTPS requests with wiremock, so we can only test // 'insecure' discovery use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + Clock, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderTokenAuthMethod, clock::MockClock, }; use mas_iana::jose::JsonWebSignatureAlg; - use mas_storage::{Clock, clock::MockClock}; use oauth2_types::scope::{OPENID, Scope}; use ulid::Ulid; use wiremock::{ @@ -427,6 +433,7 @@ mod tests { claims_imports: UpstreamOAuthProviderClaimsImports::default(), additional_authorization_parameters: Vec::new(), forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }; // Without any override, it should just use discovery diff --git a/crates/handlers/src/upstream_oauth2/callback.rs b/crates/handlers/src/upstream_oauth2/callback.rs index 75e8d63a0..7fdaf13a1 100644 --- a/crates/handlers/src/upstream_oauth2/callback.rs +++ b/crates/handlers/src/upstream_oauth2/callback.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; @@ -13,14 +13,16 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use hyper::StatusCode; -use mas_axum_utils::{cookies::CookieJar, record_error}; -use mas_data_model::{UpstreamOAuthProvider, UpstreamOAuthProviderResponseMode}; +use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar}; +use mas_data_model::{ + BoxClock, BoxRng, Clock, UpstreamOAuthProvider, UpstreamOAuthProviderResponseMode, +}; use mas_jose::claims::TokenHash; use mas_keystore::{Encrypter, Keystore}; use mas_oidc_client::requests::jose::JwtVerificationData; use mas_router::UrlBuilder; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, + BoxRepository, upstream_oauth2::{ UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository, @@ -153,15 +155,13 @@ impl_from_error_for_route!(super::cookie::UpstreamSessionNotFound); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let sentry_event_id = record_error!(self, Self::Internal(_)); - let response = match self { - Self::ProviderNotFound => (StatusCode::NOT_FOUND, "Provider not found").into_response(), - Self::SessionNotFound => (StatusCode::NOT_FOUND, "Session not found").into_response(), - Self::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - e => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - }; - - (sentry_event_id, response).into_response() + match self { + Self::Internal(e) => InternalError::new(e).into_response(), + e @ (Self::ProviderNotFound | Self::SessionNotFound) => { + GenericError::new(StatusCode::NOT_FOUND, e).into_response() + } + e => GenericError::new(StatusCode::BAD_REQUEST, e).into_response(), + } } } @@ -170,7 +170,7 @@ impl IntoResponse for RouteError { fields(upstream_oauth_provider.id = %provider_id), skip_all, )] -#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] pub(crate) async fn handler( mut rng: BoxRng, clock: BoxClock, @@ -312,6 +312,7 @@ pub(crate) async fn handler( .await?; let mut jwks = None; + let mut id_token_claims = None; let mut context = AttributeMappingContext::new(); if let Some(id_token) = token_response.id_token.as_ref() { @@ -337,6 +338,14 @@ pub(crate) async fn handler( let (_headers, mut claims) = id_token.into_parts(); + // Save a copy of the claims for later; the claims extract methods + // remove them from the map, and we want to store the original claims. + // We anyway need this to be a serde_json::Value + id_token_claims = Some( + serde_json::to_value(&claims) + .expect("serializing a HashMap into a Value should never fail"), + ); + // Access token hash must match. mas_jose::claims::AT_HASH .extract_optional_with_options( @@ -472,6 +481,7 @@ pub(crate) async fn handler( session, &link, token_response.id_token, + id_token_claims, params.extra_callback_parameters, userinfo, ) diff --git a/crates/handlers/src/upstream_oauth2/cookie.rs b/crates/handlers/src/upstream_oauth2/cookie.rs index cbcfb5148..1946b2351 100644 --- a/crates/handlers/src/upstream_oauth2/cookie.rs +++ b/crates/handlers/src/upstream_oauth2/cookie.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // TODO: move that to a standalone cookie manager use chrono::{DateTime, Duration, Utc}; use mas_axum_utils::cookies::CookieJar; +use mas_data_model::Clock; use mas_router::PostAuthAction; -use mas_storage::Clock; use serde::{Deserialize, Serialize}; use thiserror::Error; use ulid::Ulid; diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index d95854faa..d9577bafd 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock}; @@ -19,12 +19,13 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, record_error, }; +use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProviderOnConflict}; use mas_jose::jwt::Jwt; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, RepositoryAccess, + BoxRepository, RepositoryAccess, queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthSessionRepository}, user::{BrowserSessionRepository, UserEmailRepository, UserRepository}, @@ -37,7 +38,6 @@ use minijinja::Environment; use opentelemetry::{Key, KeyValue, metrics::Counter}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use tracing::warn; use ulid::Ulid; use super::{ @@ -420,8 +420,10 @@ pub(crate) async fn get( &context, provider.claims_imports.displayname.is_required(), )? { - Some(value) => ctx - .with_display_name(value, provider.claims_imports.displayname.is_forced()), + Some(value) => ctx.with_display_name( + value, + provider.claims_imports.displayname.is_forced_or_required(), + ), None => ctx, } }; @@ -442,7 +444,9 @@ pub(crate) async fn get( &context, provider.claims_imports.email.is_required(), )? { - Some(value) => ctx.with_email(value, provider.claims_imports.email.is_forced()), + Some(value) => { + ctx.with_email(value, provider.claims_imports.email.is_forced_or_required()) + } None => ctx, } }; @@ -473,19 +477,49 @@ pub(crate) async fn get( .await .map_err(RouteError::HomeserverConnection)?; - if maybe_existing_user.is_some() || !is_available { - if let Some(existing_user) = maybe_existing_user { - // The mapper returned a username which already exists, but isn't - // linked to this upstream user. - warn!(username = %localpart, user_id = %existing_user.id, "Localpart template returned an existing username"); - } + if let Some(existing_user) = maybe_existing_user { + // The mapper returned a username which already exists, but isn't + // linked to this upstream user. + let on_conflict = provider.claims_imports.localpart.on_conflict; + match on_conflict { + UpstreamOAuthProviderOnConflict::Fail => { + // TODO: translate + let ctx = ErrorContext::new() + .with_code("User exists") + .with_description(format!( + r"Upstream account provider returned {localpart:?} as username, + which is not linked to that upstream account. Your homeserver does not allow + linking an upstream account to an existing account" + )) + .with_language(&locale); + + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } + UpstreamOAuthProviderOnConflict::Add => { + // new oauth link is allowed + let ctx = UpstreamExistingLinkContext::new(existing_user) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_upstream_oauth2_login_link(&ctx)?) + .into_response(), + )); + } + } + } + + if !is_available { // TODO: translate let ctx = ErrorContext::new() - .with_code("User exists") + .with_code("Localpart not available") .with_description(format!( - r"Upstream account provider returned {localpart:?} as username, - which is not linked to that upstream account" + r"Localpart {localpart:?} is not available on this homeserver" )) .with_language(&locale); @@ -511,9 +545,9 @@ pub(crate) async fn get( // The username passes the policy check, add it to the context ctx.with_localpart( localpart, - provider.claims_imports.localpart.is_forced(), + provider.claims_imports.localpart.is_forced_or_required(), ) - } else if provider.claims_imports.localpart.is_forced() { + } else if provider.claims_imports.localpart.is_forced_or_required() { // If the username claim is 'forced' but doesn't pass the policy check, // we display an error message. // TODO: translate @@ -618,6 +652,80 @@ pub(crate) async fn post( session } + (None, None, FormData::Link) => { + // There is an existing user with the same username, but no link. + // If the configuration allows it, the user is prompted to link the + // existing account. Note that we cannot trust the user input here, + // which is why we have to re-calculate the localpart, instead of + // passing it through form data. + + let id_token = upstream_session.id_token().map(Jwt::try_from).transpose()?; + + let provider = repo + .upstream_oauth_provider() + .lookup(link.provider_id) + .await? + .ok_or(RouteError::ProviderNotFound(link.provider_id))?; + + let env = environment(); + + let mut context = AttributeMappingContext::new(); + if let Some(id_token) = id_token { + let (_, payload) = id_token.into_parts(); + context = context.with_id_token_claims(payload); + } + if let Some(extra_callback_parameters) = upstream_session.extra_callback_parameters() { + context = context.with_extra_callback_parameters(extra_callback_parameters.clone()); + } + if let Some(userinfo) = upstream_session.userinfo() { + context = context.with_userinfo_claims(userinfo.clone()); + } + let context = context.build(); + + if !provider.claims_imports.localpart.is_forced_or_required() { + //Claims import for `localpart` should be `require` or `force` at this stage + return Err(RouteError::InvalidFormAction); + } + + let template = provider + .claims_imports + .localpart + .template + .as_deref() + .unwrap_or(DEFAULT_LOCALPART_TEMPLATE); + + let Some(localpart) = render_attribute_template(&env, template, &context, true)? else { + // This should never be the case at this point + return Err(RouteError::InvalidFormAction); + }; + + let maybe_user = repo.user().find_by_username(&localpart).await?; + + let Some(user) = maybe_user else { + // user cannot be None at this stage + return Err(RouteError::InvalidFormAction); + }; + + let on_conflict = provider.claims_imports.localpart.on_conflict; + + match on_conflict { + UpstreamOAuthProviderOnConflict::Fail => { + //OnConflict can not be equals to Fail at this stage + return Err(RouteError::InvalidFormAction); + } + UpstreamOAuthProviderOnConflict::Add => { + //add link to the user + repo.upstream_oauth_link() + .associate_to_user(&link, &user) + .await?; + + repo.browser_session() + .add(&mut rng, &clock, &user, user_agent) + .await? + } + } + } + ( None, None, @@ -690,7 +798,7 @@ pub(crate) async fn post( let ctx = if let Some(ref display_name) = display_name { ctx.with_display_name( display_name.clone(), - provider.claims_imports.email.is_forced(), + provider.claims_imports.email.is_forced_or_required(), ) } else { ctx @@ -715,12 +823,15 @@ pub(crate) async fn post( }; let ctx = if let Some(ref email) = email { - ctx.with_email(email.clone(), provider.claims_imports.email.is_forced()) + ctx.with_email( + email.clone(), + provider.claims_imports.email.is_forced_or_required(), + ) } else { ctx }; - let username = if provider.claims_imports.localpart.is_forced() { + let username = if provider.claims_imports.localpart.is_forced_or_required() { let template = provider .claims_imports .localpart @@ -737,7 +848,7 @@ pub(crate) async fn post( let ctx = ctx.with_localpart( username.clone(), - provider.claims_imports.localpart.is_forced(), + provider.claims_imports.localpart.is_forced_or_required(), ); // Validate the form @@ -900,16 +1011,21 @@ pub(crate) async fn post( mod tests { use hyper::{Request, StatusCode, header::CONTENT_TYPE}; use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportPreference, + UpstreamOAuthAuthorizationSession, UpstreamOAuthLink, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderLocalpartPreference, UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_jose::jwt::{JsonWebSignatureHeader, Jwt}; + use mas_keystore::Keystore; use mas_router::Route; use mas_storage::{ - Pagination, upstream_oauth2::UpstreamOAuthProviderParams, user::UserEmailFilter, + Pagination, Repository, RepositoryError, upstream_oauth2::UpstreamOAuthProviderParams, + user::UserEmailFilter, }; use oauth2_types::scope::{OPENID, Scope}; + use rand_chacha::ChaChaRng; + use serde_json::Value; use sqlx::PgPool; use super::UpstreamSessionsCookie; @@ -923,9 +1039,10 @@ mod tests { let cookies = CookieHelper::new(); let claims_imports = UpstreamOAuthProviderClaimsImports { - localpart: UpstreamOAuthProviderImportPreference { + localpart: UpstreamOAuthProviderLocalpartPreference { action: mas_data_model::UpstreamOAuthProviderImportAction::Force, template: None, + on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(), }, email: UpstreamOAuthProviderImportPreference { action: mas_data_model::UpstreamOAuthProviderImportAction::Force, @@ -934,7 +1051,7 @@ mod tests { ..UpstreamOAuthProviderClaimsImports::default() }; - let id_token = serde_json::json!({ + let id_token_claims = serde_json::json!({ "preferred_username": "john", "email": "john@example.com", "email_verified": true, @@ -953,7 +1070,8 @@ mod tests { .signing_key_for_alg(&JsonWebSignatureAlg::Rs256) .unwrap(); let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256); - let id_token = Jwt::sign_with_rng(&mut rng, header, id_token, &signer).unwrap(); + let id_token = + Jwt::sign_with_rng(&mut rng, header, id_token_claims.clone(), &signer).unwrap(); // Provision a provider and a link let mut repo = state.repository().await.unwrap(); @@ -985,6 +1103,8 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -1022,6 +1142,7 @@ mod tests { session, &link, Some(id_token.into_string()), + Some(id_token_claims), None, None, ) @@ -1091,8 +1212,314 @@ mod tests { .list(UserEmailFilter::new().for_user(&user), Pagination::first(1)) .await .unwrap(); - let email = page.edges.first().expect("email exists"); + let edge = page.edges.first().expect("email exists"); - assert_eq!(email.email, "john@example.com"); + assert_eq!(edge.node.email, "john@example.com"); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_existing_account(pool: PgPool) { + let existing_username = "john"; + let subject = "subject"; + + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + let cookies = CookieHelper::new(); + + let claims_imports = UpstreamOAuthProviderClaimsImports { + localpart: UpstreamOAuthProviderLocalpartPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::Add, + }, + email: UpstreamOAuthProviderImportPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + }, + ..UpstreamOAuthProviderClaimsImports::default() + }; + + //`preferred_username` matches an existing user's username + let id_token_claims = serde_json::json!({ + "preferred_username": existing_username, + "email": "any@example.com", + "email_verified": true, + }); + + let id_token = sign_token(&mut rng, &state.key_store, id_token_claims.clone()).unwrap(); + + // Provision a provider and a link + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + UpstreamOAuthProviderParams { + issuer: Some("https://example.com/".to_owned()), + human_name: Some("Example Ltd.".to_owned()), + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + on_backchannel_logout: + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 0, + }, + ) + .await + .unwrap(); + + //provision upstream authorization session to setup cookies + let (link, session) = add_linked_upstream_session( + &mut rng, + &state.clock, + &mut repo, + &provider, + subject, + &id_token.into_string(), + id_token_claims, + ) + .await + .unwrap(); + + let cookie_jar = state.cookie_jar(); + let upstream_sessions = UpstreamSessionsCookie::default() + .add(session.id, provider.id, "state".to_owned(), None) + .add_link_to_session(session.id, link.id) + .unwrap(); + let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); + cookies.import(cookie_jar); + + let user = repo + .user() + .add(&mut rng, &state.clock, existing_username.to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + let request = Request::post(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).form( + serde_json::json!({ + "csrf": csrf_token, + "action": "link" + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + + // Check that the existing user has the oidc link + let mut repo = state.repository().await.unwrap(); + + let link = repo + .upstream_oauth_link() + .find_by_subject(&provider, subject) + .await + .unwrap() + .expect("link exists"); + + assert_eq!(link.user_id, Some(user.id)); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_existing_account_when_not_allowed_by_default(pool: PgPool) { + let existing_username = "john"; + + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + let cookies = CookieHelper::new(); + + let claims_imports = UpstreamOAuthProviderClaimsImports { + localpart: UpstreamOAuthProviderLocalpartPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(), + }, + email: UpstreamOAuthProviderImportPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + }, + ..UpstreamOAuthProviderClaimsImports::default() + }; + + // `preferred_username` matches an existing user's username + let id_token_claims = serde_json::json!({ + "preferred_username": existing_username, + "email": "any@example.com", + "email_verified": true, + }); + + let id_token = sign_token(&mut rng, &state.key_store, id_token_claims.clone()).unwrap(); + + // Provision a provider and a link + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + UpstreamOAuthProviderParams { + issuer: Some("https://example.com/".to_owned()), + human_name: Some("Example Ltd.".to_owned()), + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + on_backchannel_logout: + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 0, + }, + ) + .await + .unwrap(); + + let (link, session) = add_linked_upstream_session( + &mut rng, + &state.clock, + &mut repo, + &provider, + "subject", + &id_token.into_string(), + id_token_claims, + ) + .await + .unwrap(); + + // Provision an user + repo.user() + .add(&mut rng, &state.clock, existing_username.to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let upstream_sessions = UpstreamSessionsCookie::default() + .add(session.id, provider.id, "state".to_owned(), None) + .add_link_to_session(session.id, link.id) + .unwrap(); + let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); + cookies.import(cookie_jar); + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + assert!(response.body().contains("Unexpected error")); + } + + fn sign_token( + rng: &mut ChaChaRng, + keystore: &Keystore, + payload: Value, + ) -> Result, mas_jose::jwt::JwtSignatureError> { + let key = keystore + .signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256) + .unwrap(); + + let signer = key + .params() + .signing_key_for_alg(&JsonWebSignatureAlg::Rs256) + .unwrap(); + + let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256); + + Jwt::sign_with_rng(rng, header, payload, &signer) + } + + async fn add_linked_upstream_session( + rng: &mut ChaChaRng, + clock: &impl mas_data_model::Clock, + repo: &mut Box + Send + Sync + 'static>, + provider: &mas_data_model::UpstreamOAuthProvider, + subject: &str, + id_token: &str, + id_token_claims: Value, + ) -> Result<(UpstreamOAuthLink, UpstreamOAuthAuthorizationSession), anyhow::Error> { + let session = repo + .upstream_oauth_session() + .add( + rng, + clock, + provider, + "state".to_owned(), + None, + Some("nonce".to_owned()), + ) + .await?; + + let link = repo + .upstream_oauth_link() + .add(rng, clock, provider, subject.to_owned(), None) + .await?; + + let session = repo + .upstream_oauth_session() + .complete_with_link( + clock, + session, + &link, + Some(id_token.to_owned()), + Some(id_token_claims), + None, + None, + ) + .await?; + + Ok((link, session)) } } diff --git a/crates/handlers/src/upstream_oauth2/mod.rs b/crates/handlers/src/upstream_oauth2/mod.rs index c387aca1b..272af648b 100644 --- a/crates/handlers/src/upstream_oauth2/mod.rs +++ b/crates/handlers/src/upstream_oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::string::FromUtf8Error; @@ -16,6 +16,7 @@ use thiserror::Error; use url::Url; pub(crate) mod authorize; +pub(crate) mod backchannel_logout; pub(crate) mod cache; pub(crate) mod callback; mod cookie; diff --git a/crates/handlers/src/upstream_oauth2/template.rs b/crates/handlers/src/upstream_oauth2/template.rs index cdd193f09..fcf24473a 100644 --- a/crates/handlers/src/upstream_oauth2/template.rs +++ b/crates/handlers/src/upstream_oauth2/template.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, sync::Arc}; diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index 47b657436..4ae5f5222 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -1,16 +1,18 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Query, State}, + extract::State, response::{Html, IntoResponse}, }; +use axum_extra::extract::Query; use mas_axum_utils::{InternalError, cookies::CookieJar}; +use mas_data_model::{BoxClock, BoxRng}; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::BoxRepository; use mas_templates::{AppContext, TemplateContext, Templates}; use serde::Deserialize; diff --git a/crates/handlers/src/views/index.rs b/crates/handlers/src/views/index.rs index c05f4e307..0e2acccf5 100644 --- a/crates/handlers/src/views/index.rs +++ b/crates/handlers/src/views/index.rs @@ -1,16 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ extract::State, response::{Html, IntoResponse, Response}, }; use mas_axum_utils::{InternalError, cookies::CookieJar, csrf::CsrfExt}; +use mas_data_model::{BoxClock, BoxRng}; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::BoxRepository; use mas_templates::{IndexContext, TemplateContext, Templates}; use crate::{ diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 1db44285a..72e1566fe 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -1,28 +1,28 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock}; use axum::{ - extract::{Form, Query, State}, + extract::{Form, State}, response::{Html, IntoResponse, Response}, }; -use axum_extra::typed_header::TypedHeader; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use hyper::StatusCode; use mas_axum_utils::{ InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::oauth2::LoginHint; +use mas_data_model::{BoxClock, BoxRng, Clock, oauth2::LoginHint}; use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, + BoxRepository, RepositoryAccess, upstream_oauth2::UpstreamOAuthProviderRepository, user::{BrowserSessionRepository, UserPasswordRepository, UserRepository}, }; @@ -38,7 +38,7 @@ use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; use crate::{ BoundActivityTracker, Limiter, METER, PreferredLanguage, RequesterFingerprint, SiteConfig, - passwords::PasswordManager, + passwords::{PasswordManager, PasswordVerificationResult}, session::{SessionOrFallback, load_session_or_fallback}, }; @@ -123,6 +123,7 @@ pub(crate) async fn get( &mut rng, &templates, &homeserver, + &site_config, ) .await } @@ -166,6 +167,7 @@ pub(crate) async fn post( } if !form_state.is_valid() { + tracing::warn!("Invalid login form: {form_state:?}"); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); return render( locale, @@ -177,6 +179,7 @@ pub(crate) async fn post( &mut rng, &templates, &homeserver, + &site_config, ) .await; } @@ -187,8 +190,9 @@ pub(crate) async fn post( .unwrap_or(&form.username); // First, lookup the user - let Some(user) = get_user_by_email_or_by_username(site_config, &mut repo, username).await? + let Some(user) = get_user_by_email_or_by_username(&site_config, &mut repo, username).await? else { + tracing::warn!(username, "User not found"); let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); return render( @@ -201,13 +205,14 @@ pub(crate) async fn post( &mut rng, &templates, &homeserver, + &site_config, ) .await; }; // Check the rate limit if let Err(e) = limiter.check_password(requester, &user) { - tracing::warn!(error = &e as &dyn std::error::Error); + tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded"); let form_state = form_state.with_error_on_form(FormError::RateLimitExceeded); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); return render( @@ -220,6 +225,7 @@ pub(crate) async fn post( &mut rng, &templates, &homeserver, + &site_config, ) .await; } @@ -228,6 +234,7 @@ pub(crate) async fn post( let Some(user_password) = repo.user_password().active(&user).await? else { // There is no password for this user, but we don't want to disclose that. Show // a generic 'invalid credentials' error instead + tracing::warn!(username, "No password for user"); let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); return render( @@ -240,6 +247,7 @@ pub(crate) async fn post( &mut rng, &templates, &homeserver, + &site_config, ) .await; }; @@ -256,7 +264,7 @@ pub(crate) async fn post( ) .await { - Ok(Some((version, new_password_hash))) => { + Ok(PasswordVerificationResult::Success(Some((version, new_password_hash)))) => { // Save the upgraded password repo.user_password() .add( @@ -269,10 +277,11 @@ pub(crate) async fn post( ) .await? } - Ok(None) => user_password, - Err(_) => { + Ok(PasswordVerificationResult::Success(None)) => user_password, + Ok(PasswordVerificationResult::Failure) => { + tracing::warn!(username, "Failed to verify/upgrade password for user"); let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); - PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); + PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "mismatch")]); return render( locale, cookie_jar, @@ -283,14 +292,17 @@ pub(crate) async fn post( &mut rng, &templates, &homeserver, + &site_config, ) .await; } + Err(err) => return Err(InternalError::from_anyhow(err)), }; // Now that we have checked the user password, we now want to show an error if // the user is locked or deactivated if user.deactivated_at.is_some() { + tracing::warn!(username, "User is deactivated"); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let ctx = AccountInactiveContext::new(user) @@ -301,6 +313,7 @@ pub(crate) async fn post( } if user.locked_at.is_some() { + tracing::warn!(username, "User is locked"); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let ctx = AccountInactiveContext::new(user) @@ -339,7 +352,7 @@ pub(crate) async fn post( } async fn get_user_by_email_or_by_username( - site_config: SiteConfig, + site_config: &SiteConfig, repo: &mut R, username_or_email: &str, ) -> Result, R::Error> { @@ -364,6 +377,7 @@ fn handle_login_hint( mut ctx: LoginContext, next: &PostAuthContext, homeserver: &dyn HomeserverConnection, + site_config: &SiteConfig, ) -> LoginContext { let form_state = ctx.form_state_mut(); @@ -375,7 +389,10 @@ fn handle_login_hint( if let PostAuthContextInner::ContinueAuthorizationGrant { ref grant } = next.ctx { let value = match grant.parse_login_hint(homeserver.homeserver()) { LoginHint::MXID(mxid) => Some(mxid.localpart().to_owned()), - LoginHint::None => None, + LoginHint::Email(email) if site_config.login_with_email_allowed => { + Some(email.to_string()) + } + _ => None, }; form_state.set_value(LoginFormField::Username, value); } @@ -393,6 +410,7 @@ async fn render( rng: impl Rng, templates: &Templates, homeserver: &dyn HomeserverConnection, + site_config: &SiteConfig, ) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); let providers = repo.upstream_oauth_provider().all_enabled().await?; @@ -406,7 +424,7 @@ async fn render( .await .map_err(InternalError::from_anyhow)?; let ctx = if let Some(next) = next { - let ctx = handle_login_hint(ctx, &next, homeserver); + let ctx = handle_login_hint(ctx, &next, homeserver, site_config); ctx.with_post_action(next) } else { ctx @@ -424,7 +442,8 @@ mod test { header::{CONTENT_TYPE, LOCATION}, }; use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_router::Route; @@ -500,6 +519,7 @@ mod test { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -542,6 +562,7 @@ mod test { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 1, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 66bd28311..8acb9d6d0 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ extract::{Form, State}, @@ -13,8 +13,9 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; +use mas_data_model::BoxClock; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository}; +use mas_storage::{BoxRepository, user::BrowserSessionRepository}; use crate::BoundActivityTracker; @@ -33,14 +34,14 @@ pub(crate) async fn post( if let Some(session_id) = session_info.current_session_id() { let maybe_session = repo.browser_session().lookup(session_id).await?; - if let Some(session) = maybe_session { - if session.finished_at.is_none() { - activity_tracker - .record_browser_session(&clock, &session) - .await; + if let Some(session) = maybe_session + && session.finished_at.is_none() + { + activity_tracker + .record_browser_session(&clock, &session) + .await; - repo.browser_session().finish(&clock, session).await?; - } + repo.browser_session().finish(&clock, session).await?; } } diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 5d5c615e8..bc070cda7 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub mod app; pub mod index; diff --git a/crates/handlers/src/views/recovery/mod.rs b/crates/handlers/src/views/recovery/mod.rs index fefae9890..630e9905d 100644 --- a/crates/handlers/src/views/recovery/mod.rs +++ b/crates/handlers/src/views/recovery/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub mod progress; pub mod start; diff --git a/crates/handlers/src/views/recovery/progress.rs b/crates/handlers/src/views/recovery/progress.rs index ea56e6cb1..06e71f17c 100644 --- a/crates/handlers/src/views/recovery/progress.rs +++ b/crates/handlers/src/views/recovery/progress.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ Form, @@ -15,10 +15,10 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::SiteConfig; +use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_router::UrlBuilder; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, + BoxRepository, queue::{QueueJobRepositoryExt as _, SendAccountRecoveryEmailsJob}, }; use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates}; diff --git a/crates/handlers/src/views/recovery/start.rs b/crates/handlers/src/views/recovery/start.rs index 72d0bc666..f877a04db 100644 --- a/crates/handlers/src/views/recovery/start.rs +++ b/crates/handlers/src/views/recovery/start.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::str::FromStr; @@ -18,10 +18,10 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::SiteConfig; +use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_router::UrlBuilder; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, + BoxRepository, queue::{QueueJobRepositoryExt as _, SendAccountRecoveryEmailsJob}, }; use mas_templates::{ diff --git a/crates/handlers/src/views/register/cookie.rs b/crates/handlers/src/views/register/cookie.rs index 7e3eb8173..ab2d7522f 100644 --- a/crates/handlers/src/views/register/cookie.rs +++ b/crates/handlers/src/views/register/cookie.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // TODO: move that to a standalone cookie manager @@ -9,8 +9,7 @@ use std::collections::BTreeSet; use chrono::{DateTime, Duration, Utc}; use mas_axum_utils::cookies::CookieJar; -use mas_data_model::UserRegistration; -use mas_storage::Clock; +use mas_data_model::{Clock, UserRegistration}; use serde::{Deserialize, Serialize}; use thiserror::Error; use ulid::Ulid; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 3afe24573..ad7867a39 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -1,16 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Query, State}, + extract::State, response::{Html, IntoResponse, Response}, }; +use axum_extra::extract::Query; use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; -use mas_data_model::SiteConfig; +use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::BoxRepository; use mas_templates::{RegisterContext, TemplateContext, Templates}; use super::shared::OptionalPostAuthAction; diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 0745567a9..65ba5fe0d 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -1,16 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{str::FromStr, sync::Arc}; use axum::{ - extract::{Form, Query, State}, + extract::{Form, State}, response::{Html, IntoResponse, Response}, }; -use axum_extra::typed_header::TypedHeader; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use hyper::StatusCode; use lettre::Address; use mas_axum_utils::{ @@ -18,13 +18,13 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, CsrfToken, ProtectedForm}, }; -use mas_data_model::CaptchaConfig; +use mas_data_model::{BoxClock, BoxRng, CaptchaConfig}; use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, RepositoryAccess, + BoxRepository, RepositoryAccess, queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, user::{UserEmailRepository, UserRepository}, }; @@ -45,6 +45,7 @@ use crate::{ #[derive(Debug, Deserialize, Serialize)] pub(crate) struct RegisterForm { username: String, + #[serde(default)] email: String, password: String, password_confirm: String, @@ -119,7 +120,7 @@ pub(crate) async fn get( } #[tracing::instrument(name = "handlers.views.password_register.post", skip_all)] -#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, @@ -165,9 +166,16 @@ pub(crate) async fn post( .await .is_ok(); + let state = form.to_form_state(); + + // The email form is only shown if the server requires it + let email = site_config + .password_registration_email_required + .then_some(form.email); + // Validate the form let state = { - let mut state = form.to_form_state(); + let mut state = state; if !passed_captcha { state.add_error_on_form(FormError::Captcha); @@ -195,13 +203,15 @@ pub(crate) async fn post( homeserver_denied_username = true; } - // Note that we don't check here if the email is already taken here, as - // we don't want to leak the information about other users. Instead, we will - // show an error message once the user confirmed their email address. - if form.email.is_empty() { - state.add_error_on_field(RegisterFormField::Email, FieldError::Required); - } else if Address::from_str(&form.email).is_err() { - state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); + if let Some(email) = &email { + // Note that we don't check here if the email is already taken here, as + // we don't want to leak the information about other users. Instead, we will + // show an error message once the user confirmed their email address. + if email.is_empty() { + state.add_error_on_field(RegisterFormField::Email, FieldError::Required); + } else if Address::from_str(email).is_err() { + state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); + } } if form.password.is_empty() { @@ -240,7 +250,7 @@ pub(crate) async fn post( .evaluate_register(mas_policy::RegisterInput { registration_method: mas_policy::RegistrationMethod::Password, username: &form.username, - email: Some(&form.email), + email: email.as_deref(), requester: mas_policy::Requester { ip_address: activity_tracker.ip(), user_agent: user_agent.clone(), @@ -295,7 +305,9 @@ pub(crate) async fn post( state.add_error_on_form(FormError::RateLimitExceeded); } - if let Err(e) = limiter.check_email_authentication_email(requester, &form.email) { + if let Some(email) = &email + && let Err(e) = limiter.check_email_authentication_email(requester, email) + { tracing::warn!(error = &e as &dyn std::error::Error); state.add_error_on_form(FormError::RateLimitExceeded); } @@ -343,25 +355,28 @@ pub(crate) async fn post( registration }; - // Create a new user email authentication session - let user_email_authentication = repo - .user_email() - .add_authentication_for_registration(&mut rng, &clock, form.email, ®istration) - .await?; + let registration = if let Some(email) = email { + // Create a new user email authentication session + let user_email_authentication = repo + .user_email() + .add_authentication_for_registration(&mut rng, &clock, email, ®istration) + .await?; - // Schedule a job to verify the email - repo.queue_job() - .schedule_job( - &mut rng, - &clock, - SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), - ) - .await?; + // Schedule a job to verify the email + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), + ) + .await?; - let registration = repo - .user_registration() - .set_email_authentication(registration, &user_email_authentication) - .await?; + repo.user_registration() + .set_email_authentication(registration, &user_email_authentication) + .await? + } else { + registration + }; // Hash the password let password = Zeroizing::new(form.password); @@ -713,4 +728,319 @@ mod tests { response.assert_status(StatusCode::OK); assert!(response.body().contains("This username is already taken")); } + + /// Test registration without email when email is not required + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_without_email_when_not_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form without email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // The handler redirects with the ID as the second to last portion of the path + let id = location + .to_str() + .unwrap() + .rsplit('/') + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // There should be a new registration in the database + let mut repo = state.repository().await.unwrap(); + let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); + assert_eq!(registration.username, "alice".to_owned()); + assert!(registration.password.is_some()); + // Email authentication should be None when email is not required and not + // provided + assert!(registration.email_authentication_id.is_none()); + } + + /// Test registration with valid email when email is not required + /// (email input is ignored completely when not required) + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_email_when_not_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form with valid email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "charlie", + "email": "charlie@example.com", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // The handler redirects with the ID as the second to last portion of the path + let id = location + .to_str() + .unwrap() + .rsplit('/') + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // There should be a new registration in the database + let mut repo = state.repository().await.unwrap(); + let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); + assert_eq!(registration.username, "charlie".to_owned()); + assert!(registration.password.is_some()); + + // Email authentication should be None when email is not required + // (email input is completely ignored in this case) + assert!(registration.email_authentication_id.is_none()); + } + + /// Test registration fails when email is required but not provided + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_without_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form without email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "david", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Check that the response contains an error about the email field + let body = response.body(); + assert!(body.contains("email") || body.contains("Email")); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("david").await.unwrap(); + assert!(!user_exists); + } + + /// Test registration fails when email is required but empty + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_with_empty_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form with empty email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "eve", + "email": "", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Check that the response contains an error about the email field + let body = response.body(); + assert!(body.contains("email") || body.contains("Email")); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("eve").await.unwrap(); + assert!(!user_exists); + } + + /// Test registration fails with invalid email when email is required + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_with_invalid_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form with invalid email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "grace", + "email": "not-an-email", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Check that the response contains an error about the email field + let body = response.body(); + assert!(body.contains("email") || body.contains("Email")); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("grace").await.unwrap(); + assert!(!user_exists); + } } diff --git a/crates/handlers/src/views/register/steps/display_name.rs b/crates/handlers/src/views/register/steps/display_name.rs index fa029475a..0749c097d 100644 --- a/crates/handlers/src/views/register/steps/display_name.rs +++ b/crates/handlers/src/views/register/steps/display_name.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use axum::{ @@ -14,8 +14,9 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt as _, ProtectedForm}, }; +use mas_data_model::{BoxClock, BoxRng}; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::BoxRepository; use mas_templates::{ FieldError, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, TemplateContext as _, Templates, ToFormState, diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index 55fb47e71..e1ed8a3f0 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock}; @@ -13,11 +13,11 @@ use axum::{ use axum_extra::TypedHeader; use chrono::Duration; use mas_axum_utils::{InternalError, SessionInfoExt as _, cookies::CookieJar}; -use mas_data_model::SiteConfig; +use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, + BoxRepository, queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, user::UserEmailFilter, }; @@ -151,52 +151,62 @@ pub(crate) async fn get( None }; - // For now, we require an email address on the registration, but this might - // change in the future - let email_authentication_id = registration - .email_authentication_id - .context("No email authentication started for this registration") - .map_err(InternalError::from_anyhow)?; - let email_authentication = repo - .user_email() - .lookup_authentication(email_authentication_id) - .await? - .context("Could not load the email authentication") - .map_err(InternalError::from_anyhow)?; - - // Check that the email authentication has been completed - if email_authentication.completed_at.is_none() { - return Ok(( - cookie_jar, - url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), - ) - .into_response()); - } - - // Check that the email address isn't already used - // It is important to do that here, as we we're not checking during the - // registration, because we don't want to disclose whether an email is - // already being used or not before we verified it - if repo - .user_email() - .count(UserEmailFilter::new().for_email(&email_authentication.email)) - .await? - > 0 + // If there is an email authentication, we need to check that the email + // address was verified. If there is no email authentication attached, we + // need to make sure the server doesn't require it + let email_authentication = if let Some(email_authentication_id) = + registration.email_authentication_id { - let action = registration - .post_auth_action - .map(serde_json::from_value) - .transpose()?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not load the email authentication") + .map_err(InternalError::from_anyhow)?; - let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action) - .with_language(lang); + // Check that the email authentication has been completed + if email_authentication.completed_at.is_none() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), + ) + .into_response()); + } - return Ok(( - cookie_jar, - Html(templates.render_register_steps_email_in_use(&ctx)?), - ) - .into_response()); - } + // Check that the email address isn't already used + // It is important to do that here, as we we're not checking during the + // registration, because we don't want to disclose whether an email is + // already being used or not before we verified it + if repo + .user_email() + .count(UserEmailFilter::new().for_email(&email_authentication.email)) + .await? + > 0 + { + let action = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action) + .with_language(lang); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_email_in_use(&ctx)?), + ) + .into_response()); + } + + Some(email_authentication) + } else if site_config.password_registration_email_required { + // This could only happen in theory during a configuration change + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Server requires an email address to complete the registration, but no email authentication was attached to the user registration" + ))); + } else { + None + }; // Check that the display name is set if registration.display_name.is_none() { @@ -236,9 +246,11 @@ pub(crate) async fn get( .add(&mut rng, &clock, &user, user_agent) .await?; - repo.user_email() - .add(&mut rng, &clock, &user, email_authentication.email) - .await?; + if let Some(email_authentication) = email_authentication { + repo.user_email() + .add(&mut rng, &clock, &user, email_authentication.email) + .await?; + } if let Some(password) = registration.password { let user_password = repo diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs index ae57f5a0c..715934d9f 100644 --- a/crates/handlers/src/views/register/steps/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub(crate) mod display_name; pub(crate) mod finish; diff --git a/crates/handlers/src/views/register/steps/registration_token.rs b/crates/handlers/src/views/register/steps/registration_token.rs index eacf343a3..f9ad5b872 100644 --- a/crates/handlers/src/views/register/steps/registration_token.rs +++ b/crates/handlers/src/views/register/steps/registration_token.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context as _; use axum::{ @@ -14,8 +14,9 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt as _, ProtectedForm}, }; +use mas_data_model::{BoxClock, BoxRng}; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::BoxRepository; use mas_templates::{ FieldError, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField, TemplateContext as _, Templates, ToFormState, diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs index bd291d5f0..9b85626e1 100644 --- a/crates/handlers/src/views/register/steps/verify_email.rs +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context; use axum::{ @@ -13,8 +13,9 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; +use mas_data_model::{BoxClock, BoxRng}; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryAccess, user::UserEmailRepository}; +use mas_storage::{BoxRepository, RepositoryAccess, user::UserEmailRepository}; use mas_templates::{ FieldError, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, TemplateContext, Templates, ToFormState, diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 8a304ff83..05b494de8 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context; use mas_router::{PostAuthAction, Route, UrlBuilder}; diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index 3df605792..66eaac607 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-http" description = "HTTP utilities for the Matrix Authentication Service" diff --git a/crates/http/src/ext.rs b/crates/http/src/ext.rs index 838531df4..00c8a3013 100644 --- a/crates/http/src/ext.rs +++ b/crates/http/src/ext.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::OnceLock; diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs index 72c64aaba..02d1864da 100644 --- a/crates/http/src/lib.rs +++ b/crates/http/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Utilities to do HTTP requests diff --git a/crates/http/src/reqwest.rs b/crates/http/src/reqwest.rs index 561fb100f..a399a7423 100644 --- a/crates/http/src/reqwest.rs +++ b/crates/http/src/reqwest.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ str::FromStr, @@ -91,7 +91,13 @@ impl reqwest::dns::Resolve for TracingResolver { #[must_use] pub fn client() -> reqwest::Client { // TODO: can/should we limit in-flight requests? - let tls_config = rustls::ClientConfig::with_platform_verifier(); + + // The explicit typing here is because `use_preconfigured_tls` accepts + // `Any`, but wants a `ClientConfig` under the hood. This helps us detect + // breaking changes in the rustls-platform-verifier API. + let tls_config: rustls::ClientConfig = + rustls::ClientConfig::with_platform_verifier().expect("failed to create TLS config"); + reqwest::Client::builder() .dns_resolver(Arc::new(TracingResolver::new())) .use_preconfigured_tls(tls_config) diff --git a/crates/i18n-scan/Cargo.toml b/crates/i18n-scan/Cargo.toml index c70d7b4e7..cfb1bd66b 100644 --- a/crates/i18n-scan/Cargo.toml +++ b/crates/i18n-scan/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-i18n-scan" version.workspace = true diff --git a/crates/i18n-scan/src/key.rs b/crates/i18n-scan/src/key.rs index 8f5663d07..2a3b276d8 100644 --- a/crates/i18n-scan/src/key.rs +++ b/crates/i18n-scan/src/key.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_i18n::{Message, translations::TranslationTree}; use minijinja::machinery::Span; diff --git a/crates/i18n-scan/src/main.rs b/crates/i18n-scan/src/main.rs index f514d9c09..7f0824c36 100644 --- a/crates/i18n-scan/src/main.rs +++ b/crates/i18n-scan/src/main.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // Without the custom_syntax feature, the `SyntaxConfig` is a unit struct // which is annoying with this clippy lint diff --git a/crates/i18n-scan/src/minijinja.rs b/crates/i18n-scan/src/minijinja.rs index 51910783a..63aa63aea 100644 --- a/crates/i18n-scan/src/minijinja.rs +++ b/crates/i18n-scan/src/minijinja.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub use minijinja::machinery::parse; use minijinja::{ @@ -110,36 +110,36 @@ fn find_in_call<'a>( call: &'a Spanned>, ) -> Result<(), minijinja::Error> { let span = call.span(); - if let Expr::Var(var_) = &call.expr { - if var_.id == context.func() { - let key = call - .args - .first() - .and_then(as_const) - .and_then(|const_| const_.value.as_str()) - .ok_or(minijinja::Error::new( - ErrorKind::UndefinedError, - "t() first argument must be a string literal", - ))?; + if let Expr::Var(var_) = &call.expr + && var_.id == context.func() + { + let key = call + .args + .first() + .and_then(as_const) + .and_then(|const_| const_.value.as_str()) + .ok_or(minijinja::Error::new( + ErrorKind::UndefinedError, + "t() first argument must be a string literal", + ))?; - let has_count = call - .args - .iter() - .any(|arg| matches!(arg, CallArg::Kwarg("count", _))); + let has_count = call + .args + .iter() + .any(|arg| matches!(arg, CallArg::Kwarg("count", _))); - let key = Key::new( - if has_count { - crate::key::Kind::Plural - } else { - crate::key::Kind::Message - }, - key.to_owned(), - ); + let key = Key::new( + if has_count { + crate::key::Kind::Plural + } else { + crate::key::Kind::Message + }, + key.to_owned(), + ); - let key = context.set_key_location(key, span); + let key = context.set_key_location(key, span); - context.record(key); - } + context.record(key); } find_in_expr(context, &call.expr)?; diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index c73448060..8d04dc731 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-i18n" version.workspace = true diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 44fb06a5e..a4dfc7e2a 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub mod sprintf; pub mod translations; diff --git a/crates/i18n/src/sprintf/argument.rs b/crates/i18n/src/sprintf/argument.rs index 486aee58f..c83ac0e2d 100644 --- a/crates/i18n/src/sprintf/argument.rs +++ b/crates/i18n/src/sprintf/argument.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; diff --git a/crates/i18n/src/sprintf/formatter.rs b/crates/i18n/src/sprintf/formatter.rs index a0846e46f..29c71f6aa 100644 --- a/crates/i18n/src/sprintf/formatter.rs +++ b/crates/i18n/src/sprintf/formatter.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::fmt::Formatter; @@ -226,7 +226,7 @@ fn to_precision(number: f64, mut placeholder: Placeholder) -> String { } } -#[allow(clippy::too_many_lines, clippy::match_same_arms)] +#[allow(clippy::match_same_arms)] fn format_value(value: &Value, placeholder: &Placeholder) -> Result { match (value, &placeholder.type_specifier) { (Value::Number(number), ts @ TypeSpecifier::BinaryNumber) => { diff --git a/crates/i18n/src/sprintf/grammar.pest b/crates/i18n/src/sprintf/grammar.pest index 8620b0edd..6d33ad7f3 100644 --- a/crates/i18n/src/sprintf/grammar.pest +++ b/crates/i18n/src/sprintf/grammar.pest @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. message = _{ (text | percent | placeholder)* ~ EOI } diff --git a/crates/i18n/src/sprintf/message.rs b/crates/i18n/src/sprintf/message.rs index e78e4f908..f92041ac5 100644 --- a/crates/i18n/src/sprintf/message.rs +++ b/crates/i18n/src/sprintf/message.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use serde::{Deserialize, Serialize}; diff --git a/crates/i18n/src/sprintf/mod.rs b/crates/i18n/src/sprintf/mod.rs index 7fd062204..d58514f97 100644 --- a/crates/i18n/src/sprintf/mod.rs +++ b/crates/i18n/src/sprintf/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(unused_macros)] @@ -72,6 +72,7 @@ pub(crate) use sprintf; #[derive(Debug, thiserror::Error)] #[error(transparent)] +#[allow(dead_code)] enum Error { Format(#[from] self::formatter::FormatError), Parse(Box), diff --git a/crates/i18n/src/sprintf/parser.rs b/crates/i18n/src/sprintf/parser.rs index cadb23ed7..ab5aaf87b 100644 --- a/crates/i18n/src/sprintf/parser.rs +++ b/crates/i18n/src/sprintf/parser.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::result_large_err)] diff --git a/crates/i18n/src/translations.rs b/crates/i18n/src/translations.rs index 8e90b5204..9bde8fa05 100644 --- a/crates/i18n/src/translations.rs +++ b/crates/i18n/src/translations.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ collections::{BTreeMap, BTreeSet}, diff --git a/crates/i18n/src/translator.rs b/crates/i18n/src/translator.rs index 68afb1793..cd36a6c4b 100644 --- a/crates/i18n/src/translator.rs +++ b/crates/i18n/src/translator.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, fs::File, io::BufReader, str::FromStr}; diff --git a/crates/iana-codegen/Cargo.toml b/crates/iana-codegen/Cargo.toml index ae8f8d1ed..0a8e8669b 100644 --- a/crates/iana-codegen/Cargo.toml +++ b/crates/iana-codegen/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-iana-codegen" version.workspace = true @@ -18,6 +23,7 @@ camino.workspace = true convert_case.workspace = true csv.workspace = true reqwest.workspace = true +rustls.workspace = true serde.workspace = true tokio.workspace = true tracing-subscriber.workspace = true diff --git a/crates/iana-codegen/src/generation.rs b/crates/iana-codegen/src/generation.rs index 8558ef389..2f1d4836e 100644 --- a/crates/iana-codegen/src/generation.rs +++ b/crates/iana-codegen/src/generation.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use crate::traits::{EnumMember, Section}; diff --git a/crates/iana-codegen/src/jose.rs b/crates/iana-codegen/src/jose.rs index 9f94cdf81..36d00de74 100644 --- a/crates/iana-codegen/src/jose.rs +++ b/crates/iana-codegen/src/jose.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use serde::Deserialize; diff --git a/crates/iana-codegen/src/main.rs b/crates/iana-codegen/src/main.rs index b5b1c28eb..d2ff734e0 100644 --- a/crates/iana-codegen/src/main.rs +++ b/crates/iana-codegen/src/main.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, fmt::Display}; @@ -71,15 +71,14 @@ impl File { } impl Display for File { - #[allow(clippy::too_many_lines)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!( f, - r"// Copyright 2024 New Vector Ltd. + r"// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::doc_markdown)] @@ -190,6 +189,10 @@ async fn main() -> anyhow::Result<()> { .pretty() .init(); + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + #[expect( clippy::disallowed_methods, reason = "reqwest::Client::new should be disallowed by clippy, but for the codegen it's fine" diff --git a/crates/iana-codegen/src/oauth.rs b/crates/iana-codegen/src/oauth.rs index 9afc62659..2809bb7db 100644 --- a/crates/iana-codegen/src/oauth.rs +++ b/crates/iana-codegen/src/oauth.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use serde::Deserialize; diff --git a/crates/iana-codegen/src/traits.rs b/crates/iana-codegen/src/traits.rs index c080f901c..c5a2617e6 100644 --- a/crates/iana-codegen/src/traits.rs +++ b/crates/iana-codegen/src/traits.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context; use async_trait::async_trait; diff --git a/crates/iana/Cargo.toml b/crates/iana/Cargo.toml index 796abe4ba..d646bac19 100644 --- a/crates/iana/Cargo.toml +++ b/crates/iana/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-iana" description = "IANA registry data for JOSE and OAuth 2.0" diff --git a/crates/iana/src/jose.rs b/crates/iana/src/jose.rs index 5f9a84ce4..8a0434fad 100644 --- a/crates/iana/src/jose.rs +++ b/crates/iana/src/jose.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::doc_markdown)] diff --git a/crates/iana/src/lib.rs b/crates/iana/src/lib.rs index 59f8b3bf1..657e7375c 100644 --- a/crates/iana/src/lib.rs +++ b/crates/iana/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Values from IANA registries, generated by the `mas-iana-codegen` crate diff --git a/crates/iana/src/oauth.rs b/crates/iana/src/oauth.rs index e1835e16c..f59cd7c8c 100644 --- a/crates/iana/src/oauth.rs +++ b/crates/iana/src/oauth.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::doc_markdown)] diff --git a/crates/jose/Cargo.toml b/crates/jose/Cargo.toml index 9ca221bb3..ac9c4d96a 100644 --- a/crates/jose/Cargo.toml +++ b/crates/jose/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-jose" description = "JSON Object Signing and Encryption (JWT & co) utilities" diff --git a/crates/jose/src/base64.rs b/crates/jose/src/base64.rs index b5b0b2386..5886368ab 100644 --- a/crates/jose/src/base64.rs +++ b/crates/jose/src/base64.rs @@ -1,3 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + //! Transparent base64 encoding / decoding as part of (de)serialization. use std::{borrow::Cow, fmt, marker::PhantomData, str}; diff --git a/crates/jose/src/claims.rs b/crates/jose/src/claims.rs index 455be2ff9..3ab6e26e0 100644 --- a/crates/jose/src/claims.rs +++ b/crates/jose/src/claims.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, convert::Infallible, marker::PhantomData, ops::Deref}; @@ -49,7 +49,7 @@ impl Validator for () { } pub struct Claim { - claim: &'static str, + value: &'static str, t: PhantomData, v: PhantomData, } @@ -61,7 +61,7 @@ where #[must_use] pub const fn new(claim: &'static str) -> Self { Self { - claim, + value: claim, t: PhantomData, v: PhantomData, } @@ -83,8 +83,8 @@ where { let value = value.into(); let value: serde_json::Value = - serde_json::to_value(&value).map_err(|_| ClaimError::InvalidClaim(self.claim))?; - claims.insert(self.claim.to_owned(), value); + serde_json::to_value(&value).map_err(|_| ClaimError::InvalidClaim(self.value))?; + claims.insert(self.value.to_owned(), value); Ok(()) } @@ -126,15 +126,15 @@ where { let validator: V = validator.into(); let claim = claims - .remove(self.claim) - .ok_or(ClaimError::MissingClaim(self.claim))?; + .remove(self.value) + .ok_or(ClaimError::MissingClaim(self.value))?; let res = - serde_json::from_value(claim).map_err(|_| ClaimError::InvalidClaim(self.claim))?; + serde_json::from_value(claim).map_err(|_| ClaimError::InvalidClaim(self.value))?; validator .validate(&res) .map_err(|source| ClaimError::ValidationError { - claim: self.claim, + claim: self.value, source: Box::new(source), })?; Ok(res) @@ -182,6 +182,22 @@ where Err(e) => Err(e), } } + + /// Assert that the claim is absent. + /// + /// # Errors + /// + /// Returns an error if the claim is present. + pub fn assert_absent( + &self, + claims: &HashMap, + ) -> Result<(), ClaimError> { + if claims.contains_key(self.value) { + Err(ClaimError::InvalidClaim(self.value)) + } else { + Ok(()) + } + } } #[derive(Debug, Clone)] @@ -525,7 +541,15 @@ mod oidc_core { pub const UPDATED_AT: Claim = Claim::new("updated_at"); } -pub use self::{oidc_core::*, rfc7519::*}; +/// Claims defined in OpenID.FrontChannel +/// +mod oidc_frontchannel { + use super::Claim; + + pub const SID: Claim = Claim::new("sid"); +} + +pub use self::{oidc_core::*, oidc_frontchannel::*, rfc7519::*}; #[cfg(test)] mod tests { diff --git a/crates/jose/src/constraints.rs b/crates/jose/src/constraints.rs index 23e0ec712..16859c889 100644 --- a/crates/jose/src/constraints.rs +++ b/crates/jose/src/constraints.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashSet; diff --git a/crates/jose/src/jwa/asymmetric.rs b/crates/jose/src/jwa/asymmetric.rs index 21472deb6..b490d5796 100644 --- a/crates/jose/src/jwa/asymmetric.rs +++ b/crates/jose/src/jwa/asymmetric.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use digest::Digest; use mas_iana::jose::{JsonWebKeyEcEllipticCurve, JsonWebSignatureAlg}; diff --git a/crates/jose/src/jwa/hmac.rs b/crates/jose/src/jwa/hmac.rs index af7bd59d9..ba24ee960 100644 --- a/crates/jose/src/jwa/hmac.rs +++ b/crates/jose/src/jwa/hmac.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::marker::PhantomData; diff --git a/crates/jose/src/jwa/mod.rs b/crates/jose/src/jwa/mod.rs index 690b57993..f01308575 100644 --- a/crates/jose/src/jwa/mod.rs +++ b/crates/jose/src/jwa/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_iana::jose::JsonWebSignatureAlg; use sha2::{Sha256, Sha384, Sha512}; diff --git a/crates/jose/src/jwa/signature.rs b/crates/jose/src/jwa/signature.rs index af72f3fcc..35c3cb978 100644 --- a/crates/jose/src/jwa/signature.rs +++ b/crates/jose/src/jwa/signature.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use signature::SignatureEncoding as _; diff --git a/crates/jose/src/jwa/symmetric.rs b/crates/jose/src/jwa/symmetric.rs index b5d821dbc..3e45b6ab0 100644 --- a/crates/jose/src/jwa/symmetric.rs +++ b/crates/jose/src/jwa/symmetric.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_iana::jose::JsonWebSignatureAlg; use thiserror::Error; diff --git a/crates/jose/src/jwk/mod.rs b/crates/jose/src/jwk/mod.rs index 8c397239c..f794620ba 100644 --- a/crates/jose/src/jwk/mod.rs +++ b/crates/jose/src/jwk/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Ref: @@ -13,6 +13,7 @@ use mas_iana::jose::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; +use sha2::{Digest, Sha256}; use url::Url; use crate::{ @@ -239,6 +240,28 @@ impl

JsonWebKey

{ } } +/// Methods to calculate RFC 7638 JWK Thumbprints. +pub trait Thumbprint { + /// Returns the RFC 7638 JWK Thumbprint JSON string. + fn thumbprint_prehashed(&self) -> String; + + /// Returns the RFC 7638 SHA256-hashed JWK Thumbprint. + fn thumbprint_sha256(&self) -> [u8; 32] { + Sha256::digest(self.thumbprint_prehashed()).into() + } + + /// Returns the RFC 7638 SHA256-hashed JWK Thumbprint as base64url string. + fn thumbprint_sha256_base64(&self) -> String { + Base64UrlNoPad::new(self.thumbprint_sha256().into()).encode() + } +} + +impl Thumbprint for JsonWebKey

{ + fn thumbprint_prehashed(&self) -> String { + self.parameters.thumbprint_prehashed() + } +} + impl

Constrainable for JsonWebKey

where P: ParametersInfo, @@ -413,7 +436,6 @@ mod tests { assert_eq!(candidates.len(), 1); } - #[allow(clippy::too_many_lines)] #[test] fn load_keycloak_keys() { let jwks = serde_json::json!({ diff --git a/crates/jose/src/jwk/private_parameters.rs b/crates/jose/src/jwk/private_parameters.rs index d0ac8be87..6518e5ba4 100644 --- a/crates/jose/src/jwk/private_parameters.rs +++ b/crates/jose/src/jwk/private_parameters.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_iana::jose::{ JsonWebKeyEcEllipticCurve, JsonWebKeyOkpEllipticCurve, JsonWebKeyType, JsonWebSignatureAlg, diff --git a/crates/jose/src/jwk/public_parameters.rs b/crates/jose/src/jwk/public_parameters.rs index 1b5eaadbf..f4b57c532 100644 --- a/crates/jose/src/jwk/public_parameters.rs +++ b/crates/jose/src/jwk/public_parameters.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_iana::jose::{ JsonWebKeyEcEllipticCurve, JsonWebKeyOkpEllipticCurve, JsonWebKeyType, JsonWebSignatureAlg, @@ -11,7 +11,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::ParametersInfo; -use crate::base64::Base64UrlNoPad; +use crate::{base64::Base64UrlNoPad, jwk::Thumbprint}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kty")] @@ -52,6 +52,22 @@ impl JsonWebKeyPublicParameters { } } +impl Thumbprint for JsonWebKeyPublicParameters { + fn thumbprint_prehashed(&self) -> String { + match self { + JsonWebKeyPublicParameters::Rsa(RsaPublicParameters { n, e }) => { + format!("{{\"e\":\"{e}\",\"kty\":\"RSA\",\"n\":\"{n}\"}}") + } + JsonWebKeyPublicParameters::Ec(EcPublicParameters { crv, x, y }) => { + format!("{{\"crv\":\"{crv}\",\"kty\":\"EC\",\"x\":\"{x}\",\"y\":\"{y}\"}}") + } + JsonWebKeyPublicParameters::Okp(OkpPublicParameters { crv, x }) => { + format!("{{\"crv\":\"{crv}\",\"kty\":\"OKP\",\"x\":\"{x}\"}}") + } + } + } +} + impl ParametersInfo for JsonWebKeyPublicParameters { fn kty(&self) -> JsonWebKeyType { match self { @@ -300,3 +316,31 @@ mod ec_impls { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_thumbprint_rfc_example() { + // From https://www.rfc-editor.org/rfc/rfc7638.html#section-3.1 + let n = Base64UrlNoPad::parse( + "\ + 0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt\ + VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6\ + 4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD\ + W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9\ + 1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH\ + aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + ) + .unwrap(); + let e = Base64UrlNoPad::parse("AQAB").unwrap(); + + let jwkpps = JsonWebKeyPublicParameters::Rsa(RsaPublicParameters { n, e }); + + assert_eq!( + jwkpps.thumbprint_sha256_base64(), + "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" + ); + } +} diff --git a/crates/jose/src/jwt/header.rs b/crates/jose/src/jwt/header.rs index 56aaef6d8..c35e9fdb4 100644 --- a/crates/jose/src/jwt/header.rs +++ b/crates/jose/src/jwt/header.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_iana::jose::JsonWebSignatureAlg; use serde::{Deserialize, Serialize}; diff --git a/crates/jose/src/jwt/mod.rs b/crates/jose/src/jwt/mod.rs index 15cf20f2f..50a777195 100644 --- a/crates/jose/src/jwt/mod.rs +++ b/crates/jose/src/jwt/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod header; mod raw; diff --git a/crates/jose/src/jwt/raw.rs b/crates/jose/src/jwt/raw.rs index 869150346..46193f993 100644 --- a/crates/jose/src/jwt/raw.rs +++ b/crates/jose/src/jwt/raw.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{borrow::Cow, ops::Deref}; diff --git a/crates/jose/src/jwt/signed.rs b/crates/jose/src/jwt/signed.rs index ba7a552c8..3d632c05b 100644 --- a/crates/jose/src/jwt/signed.rs +++ b/crates/jose/src/jwt/signed.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use base64ct::{Base64UrlUnpadded, Encoding}; use rand::thread_rng; diff --git a/crates/jose/src/lib.rs b/crates/jose/src/lib.rs index 9f9bf2348..3384d9f1c 100644 --- a/crates/jose/src/lib.rs +++ b/crates/jose/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(rustdoc::broken_intra_doc_links)] #![allow(clippy::module_name_repetitions)] diff --git a/crates/jose/tests/generate.py b/crates/jose/tests/generate.py index d66f55369..e70f5725c 100644 --- a/crates/jose/tests/generate.py +++ b/crates/jose/tests/generate.py @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + # Generates test keys, JWKS and JWTs # Required the `openssl` binary and the `authlib` python library diff --git a/crates/jose/tests/jws.rs b/crates/jose/tests/jws.rs index e6947d5a0..d7068cdad 100644 --- a/crates/jose/tests/jws.rs +++ b/crates/jose/tests/jws.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. static HS256_JWT: &str = include_str!("./jwts/hs256.jwt"); static HS384_JWT: &str = include_str!("./jwts/hs384.jwt"); diff --git a/crates/keystore/Cargo.toml b/crates/keystore/Cargo.toml index 668d555ad..0b97e5d88 100644 --- a/crates/keystore/Cargo.toml +++ b/crates/keystore/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-keystore" description = "Secret keys store used by the Matrix Authentication Service" diff --git a/crates/keystore/src/encrypter.rs b/crates/keystore/src/encrypter.rs index d4d61154d..a2cb738e0 100644 --- a/crates/keystore/src/encrypter.rs +++ b/crates/keystore/src/encrypter.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; diff --git a/crates/keystore/src/lib.rs b/crates/keystore/src/lib.rs index 639afcb0d..fa9d305bb 100644 --- a/crates/keystore/src/lib.rs +++ b/crates/keystore/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A crate to store keys which can then be used to sign and verify JWTs. @@ -14,7 +14,7 @@ use mas_iana::jose::{JsonWebKeyType, JsonWebSignatureAlg}; pub use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_jose::{ jwa::{AsymmetricSigningKey, AsymmetricVerifyingKey}, - jwk::{JsonWebKeyPublicParameters, ParametersInfo, PublicJsonWebKeySet}, + jwk::{JsonWebKeyPublicParameters, ParametersInfo, PublicJsonWebKeySet, Thumbprint}, }; use pem_rfc7468::PemLabel; use pkcs1::EncodeRsaPrivateKey; @@ -599,6 +599,12 @@ impl ParametersInfo for PrivateKey { } } +impl Thumbprint for PrivateKey { + fn thumbprint_prehashed(&self) -> String { + JsonWebKeyPublicParameters::from(self).thumbprint_prehashed() + } +} + /// A structure to store a list of [`PrivateKey`]. The keys are held in an /// [`Arc`] to ensure they are only loaded once in memory and allow cheap /// cloning diff --git a/crates/keystore/tests/generate.sh b/crates/keystore/tests/generate.sh index c2de69c9f..b6cbe1082 100644 --- a/crates/keystore/tests/generate.sh +++ b/crates/keystore/tests/generate.sh @@ -1,4 +1,8 @@ #!/bin/sh +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. set -eux diff --git a/crates/keystore/tests/keystore.rs b/crates/keystore/tests/keystore.rs index 6bdbe8286..08ad2ddc2 100644 --- a/crates/keystore/tests/keystore.rs +++ b/crates/keystore/tests/keystore.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use der::pem::LineEnding; use mas_iana::jose::JsonWebSignatureAlg; diff --git a/crates/listener/Cargo.toml b/crates/listener/Cargo.toml index 14d84ea9b..5171e86b4 100644 --- a/crates/listener/Cargo.toml +++ b/crates/listener/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-listener" version.workspace = true diff --git a/crates/listener/examples/demo/certs/gen.sh b/crates/listener/examples/demo/certs/gen.sh index 4ce16dcc3..019279ec1 100644 --- a/crates/listener/examples/demo/certs/gen.sh +++ b/crates/listener/examples/demo/certs/gen.sh @@ -1,4 +1,8 @@ #!/bin/sh +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. # Script to regenerate the server and client certificate diff --git a/crates/listener/examples/demo/main.rs b/crates/listener/examples/demo/main.rs index 1a950fa6d..2a0462309 100644 --- a/crates/listener/examples/demo/main.rs +++ b/crates/listener/examples/demo/main.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ convert::Infallible, diff --git a/crates/listener/src/lib.rs b/crates/listener/src/lib.rs index 74ded2388..1618365b1 100644 --- a/crates/listener/src/lib.rs +++ b/crates/listener/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(rustdoc::missing_crate_level_docs)] #![allow(clippy::module_name_repetitions)] diff --git a/crates/listener/src/maybe_tls.rs b/crates/listener/src/maybe_tls.rs index 635141cbc..02fec8946 100644 --- a/crates/listener/src/maybe_tls.rs +++ b/crates/listener/src/maybe_tls.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ pin::Pin, diff --git a/crates/listener/src/proxy_protocol/acceptor.rs b/crates/listener/src/proxy_protocol/acceptor.rs index 7ca24bd56..d62ab6182 100644 --- a/crates/listener/src/proxy_protocol/acceptor.rs +++ b/crates/listener/src/proxy_protocol/acceptor.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use bytes::BytesMut; use thiserror::Error; diff --git a/crates/listener/src/proxy_protocol/maybe.rs b/crates/listener/src/proxy_protocol/maybe.rs index 29e47f047..47c4860e8 100644 --- a/crates/listener/src/proxy_protocol/maybe.rs +++ b/crates/listener/src/proxy_protocol/maybe.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use tokio::io::AsyncRead; diff --git a/crates/listener/src/proxy_protocol/mod.rs b/crates/listener/src/proxy_protocol/mod.rs index 820ef428a..5f9929fed 100644 --- a/crates/listener/src/proxy_protocol/mod.rs +++ b/crates/listener/src/proxy_protocol/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod acceptor; mod maybe; diff --git a/crates/listener/src/proxy_protocol/v1.rs b/crates/listener/src/proxy_protocol/v1.rs index 93a5dabd1..bb78aa654 100644 --- a/crates/listener/src/proxy_protocol/v1.rs +++ b/crates/listener/src/proxy_protocol/v1.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ net::{AddrParseError, Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -52,7 +52,6 @@ impl ParseError { } impl ProxyProtocolV1Info { - #[allow(clippy::too_many_lines)] pub(super) fn parse(buf: &mut B) -> Result where B: Buf + AsRef<[u8]>, diff --git a/crates/listener/src/server.rs b/crates/listener/src/server.rs index f0b66820d..2a0b6ccda 100644 --- a/crates/listener/src/server.rs +++ b/crates/listener/src/server.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ pin::Pin, @@ -279,18 +279,17 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let mut this = self.project(); - if let Poll::Ready(()) = this.cancellation_future.poll(cx) { - if !*this.did_start_shutdown { - *this.did_start_shutdown = true; - this.connection.as_mut().graceful_shutdown(); - } + if let Poll::Ready(()) = this.cancellation_future.poll(cx) + && !*this.did_start_shutdown + { + *this.did_start_shutdown = true; + this.connection.as_mut().graceful_shutdown(); } this.connection.poll(cx) } } -#[allow(clippy::too_many_lines)] pub async fn run_servers( listeners: impl IntoIterator>, soft_shutdown_token: CancellationToken, diff --git a/crates/listener/src/unix_or_tcp.rs b/crates/listener/src/unix_or_tcp.rs index 565adec4d..5cd85c441 100644 --- a/crates/listener/src/unix_or_tcp.rs +++ b/crates/listener/src/unix_or_tcp.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A listener which can listen on either TCP sockets or on UNIX domain sockets @@ -152,7 +152,6 @@ impl UnixOrTcpListener { let socket = socket2::SockRef::from(&stream); socket.set_keepalive(true)?; - socket.set_nodelay(true)?; Ok((remote_addr.into(), UnixOrTcpConnection::Unix { stream })) } @@ -161,7 +160,7 @@ impl UnixOrTcpListener { let socket = socket2::SockRef::from(&stream); socket.set_keepalive(true)?; - socket.set_nodelay(true)?; + socket.set_tcp_nodelay(true)?; Ok((remote_addr.into(), UnixOrTcpConnection::Tcp { stream })) } @@ -188,7 +187,6 @@ impl UnixOrTcpListener { let socket = socket2::SockRef::from(&stream); socket.set_keepalive(true)?; - socket.set_nodelay(true)?; Poll::Ready(Ok(( remote_addr.into(), @@ -200,7 +198,7 @@ impl UnixOrTcpListener { let socket = socket2::SockRef::from(&stream); socket.set_keepalive(true)?; - socket.set_nodelay(true)?; + socket.set_tcp_nodelay(true)?; Poll::Ready(Ok(( remote_addr.into(), diff --git a/crates/matrix-synapse/Cargo.toml b/crates/matrix-synapse/Cargo.toml index 8e3e1841c..34c08e9c9 100644 --- a/crates/matrix-synapse/Cargo.toml +++ b/crates/matrix-synapse/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-matrix-synapse" version.workspace = true diff --git a/crates/matrix-synapse/src/error.rs b/crates/matrix-synapse/src/error.rs index bcb31157b..c1d98ccd1 100644 --- a/crates/matrix-synapse/src/error.rs +++ b/crates/matrix-synapse/src/error.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::fmt::Display; @@ -9,6 +9,16 @@ use async_trait::async_trait; use serde::Deserialize; use thiserror::Error; +/// Encountered when trying to register a user ID which has been taken. +/// — +pub(crate) const M_USER_IN_USE: &str = "M_USER_IN_USE"; +/// Encountered when trying to register a user ID which is not valid. +/// — +pub(crate) const M_INVALID_USERNAME: &str = "M_INVALID_USERNAME"; +/// Encountered when trying to register a user ID reserved by an appservice. +/// — +pub(crate) const M_EXCLUSIVE: &str = "M_EXCLUSIVE"; + /// Represents a Matrix error /// Ref: #[derive(Debug, Deserialize)] diff --git a/crates/matrix-synapse/src/legacy.rs b/crates/matrix-synapse/src/legacy.rs new file mode 100644 index 000000000..b93298ceb --- /dev/null +++ b/crates/matrix-synapse/src/legacy.rs @@ -0,0 +1,688 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::{collections::HashSet, time::Duration}; + +use anyhow::{Context, bail}; +use http::{Method, StatusCode}; +use mas_http::RequestBuilderExt as _; +use mas_matrix::{HomeserverConnection, MatrixUser, ProvisionRequest}; +use serde::{Deserialize, Serialize}; +use tracing::debug; +use url::Url; + +use crate::error::{M_EXCLUSIVE, M_INVALID_USERNAME, M_USER_IN_USE, SynapseResponseExt}; + +static SYNAPSE_AUTH_PROVIDER: &str = "oauth-delegated"; + +#[derive(Clone)] +pub struct SynapseConnection { + homeserver: String, + endpoint: Url, + access_token: String, + http_client: reqwest::Client, +} + +impl SynapseConnection { + #[must_use] + pub fn new( + homeserver: String, + endpoint: Url, + access_token: String, + http_client: reqwest::Client, + ) -> Self { + Self { + homeserver, + endpoint, + access_token, + http_client, + } + } + + fn builder(&self, method: Method, url: &str) -> reqwest::RequestBuilder { + self.http_client + .request( + method, + self.endpoint + .join(url) + .map(String::from) + .unwrap_or_default(), + ) + .bearer_auth(&self.access_token) + } + + fn post(&self, url: &str) -> reqwest::RequestBuilder { + self.builder(Method::POST, url) + } + + fn get(&self, url: &str) -> reqwest::RequestBuilder { + self.builder(Method::GET, url) + } + + fn put(&self, url: &str) -> reqwest::RequestBuilder { + self.builder(Method::PUT, url) + } + + fn delete(&self, url: &str) -> reqwest::RequestBuilder { + self.builder(Method::DELETE, url) + } +} + +#[derive(Serialize, Deserialize)] +struct ExternalID { + auth_provider: String, + external_id: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ThreePIDMedium { + Email, + Msisdn, +} + +#[derive(Serialize, Deserialize)] +struct ThreePID { + medium: ThreePIDMedium, + address: String, +} + +#[derive(Default, Serialize, Deserialize)] +struct SynapseUser { + #[serde( + default, + rename = "displayname", + skip_serializing_if = "Option::is_none" + )] + display_name: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + avatar_url: Option, + + #[serde(default, rename = "threepids", skip_serializing_if = "Option::is_none")] + three_pids: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + external_ids: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + deactivated: Option, +} + +#[derive(Deserialize)] +struct SynapseDeviceListResponse { + devices: Vec, +} + +#[derive(Serialize, Deserialize)] +struct SynapseDevice { + device_id: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + dehydrated: Option, +} + +#[derive(Serialize)] +struct SynapseUpdateDeviceRequest<'a> { + display_name: Option<&'a str>, +} + +#[derive(Serialize)] +struct SynapseDeleteDevicesRequest { + devices: Vec, +} + +#[derive(Serialize)] +struct SetDisplayNameRequest<'a> { + displayname: &'a str, +} + +#[derive(Serialize)] +struct SynapseDeactivateUserRequest { + erase: bool, +} + +#[derive(Serialize)] +struct SynapseAllowCrossSigningResetRequest {} + +/// Response body of +/// `/_synapse/admin/v1/username_available?username={localpart}` +#[derive(Deserialize)] +struct UsernameAvailableResponse { + available: bool, +} + +#[async_trait::async_trait] +impl HomeserverConnection for SynapseConnection { + fn homeserver(&self) -> &str { + &self.homeserver + } + + #[tracing::instrument(name = "homeserver.verify_token", skip_all, err(Debug))] + async fn verify_token(&self, token: &str) -> Result { + Ok(self.access_token == token) + } + + #[tracing::instrument( + name = "homeserver.query_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn query_user(&self, localpart: &str) -> Result { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + + let response = self + .get(&format!("_synapse/admin/v2/users/{encoded_mxid}")) + .send_traced() + .await + .context("Failed to query user from Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while querying user from Synapse")?; + + let body: SynapseUser = response + .json() + .await + .context("Failed to deserialize response while querying user from Synapse")?; + + Ok(MatrixUser { + displayname: body.display_name, + avatar_url: body.avatar_url, + deactivated: body.deactivated.unwrap_or(false), + }) + } + + #[tracing::instrument( + name = "homeserver.is_localpart_available", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn is_localpart_available(&self, localpart: &str) -> Result { + // Synapse will give us a M_UNKNOWN error if the localpart is not ASCII, + // so we bail out early + if !localpart.is_ascii() { + return Ok(false); + } + + let localpart = urlencoding::encode(localpart); + + let response = self + .get(&format!( + "_synapse/admin/v1/username_available?username={localpart}" + )) + .send_traced() + .await + .context("Failed to query localpart availability from Synapse")?; + + match response.error_for_synapse_error().await { + Ok(resp) => { + let response: UsernameAvailableResponse = resp.json().await.context( + "Unexpected response while querying localpart availability from Synapse", + )?; + + Ok(response.available) + } + + Err(err) + if err.errcode() == Some(M_INVALID_USERNAME) + || err.errcode() == Some(M_USER_IN_USE) + || err.errcode() == Some(M_EXCLUSIVE) => + { + debug!( + error = &err as &dyn std::error::Error, + "Localpart is not available" + ); + Ok(false) + } + + Err(err) => Err(err).context("Failed to query localpart availability from Synapse"), + } + } + + #[tracing::instrument( + name = "homeserver.provision_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = request.localpart(), + user.id = request.sub(), + ), + err(Debug), + )] + async fn provision_user(&self, request: &ProvisionRequest) -> Result { + let mut body = SynapseUser { + external_ids: Some(vec![ExternalID { + auth_provider: SYNAPSE_AUTH_PROVIDER.to_owned(), + external_id: request.sub().to_owned(), + }]), + ..SynapseUser::default() + }; + + request + .on_displayname(|displayname| { + body.display_name = Some(displayname.unwrap_or_default().to_owned()); + }) + .on_avatar_url(|avatar_url| { + body.avatar_url = Some(avatar_url.unwrap_or_default().to_owned()); + }) + .on_emails(|emails| { + body.three_pids = Some( + emails + .unwrap_or_default() + .iter() + .map(|email| ThreePID { + medium: ThreePIDMedium::Email, + address: email.clone(), + }) + .collect(), + ); + }); + + let mxid = self.mxid(request.localpart()); + let encoded_mxid = urlencoding::encode(&mxid); + let response = self + .put(&format!("_synapse/admin/v2/users/{encoded_mxid}")) + .json(&body) + .send_traced() + .await + .context("Failed to provision user in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while provisioning user in Synapse")?; + + match response.status() { + StatusCode::CREATED => Ok(true), + StatusCode::OK => Ok(false), + code => bail!("Unexpected HTTP code while provisioning user in Synapse: {code}"), + } + } + + #[tracing::instrument( + name = "homeserver.upsert_device", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn upsert_device( + &self, + localpart: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + + let response = self + .post(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) + .json(&SynapseDevice { + device_id: device_id.to_owned(), + dehydrated: None, + }) + .send_traced() + .await + .context("Failed to create device in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while creating device in Synapse")?; + + if response.status() != StatusCode::CREATED { + bail!( + "Unexpected HTTP code while creating device in Synapse: {}", + response.status() + ); + } + + // It's annoying, but the POST endpoint doesn't let us set the display name + // of the device, so we have to do it manually. + if let Some(display_name) = initial_display_name { + self.update_device_display_name(localpart, device_id, display_name) + .await?; + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.update_device_display_name", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn update_device_display_name( + &self, + localpart: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + let device_id = urlencoding::encode(device_id); + let response = self + .put(&format!( + "_synapse/admin/v2/users/{encoded_mxid}/devices/{device_id}" + )) + .json(&SynapseUpdateDeviceRequest { + display_name: Some(display_name), + }) + .send_traced() + .await + .context("Failed to update device display name in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while updating device display name in Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while updating device display name in Synapse: {}", + response.status() + ); + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.delete_device", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn delete_device(&self, localpart: &str, device_id: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + let encoded_device_id = urlencoding::encode(device_id); + + let response = self + .delete(&format!( + "_synapse/admin/v2/users/{encoded_mxid}/devices/{encoded_device_id}" + )) + .send_traced() + .await + .context("Failed to delete device in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while deleting device in Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while deleting device in Synapse: {}", + response.status() + ); + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.sync_devices", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn sync_devices( + &self, + localpart: &str, + devices: HashSet, + ) -> Result<(), anyhow::Error> { + // Get the list of current devices + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + + let response = self + .get(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) + .send_traced() + .await + .context("Failed to query devices from Synapse")?; + + let response = response.error_for_synapse_error().await?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while querying devices from Synapse: {}", + response.status() + ); + } + + let body: SynapseDeviceListResponse = response + .json() + .await + .context("Failed to parse response while querying devices from Synapse")?; + + let existing_devices: HashSet = body + .devices + .into_iter() + .filter(|d| d.dehydrated != Some(true)) + .map(|d| d.device_id) + .collect(); + + // First, delete all the devices that are not needed anymore + let to_delete = existing_devices.difference(&devices).cloned().collect(); + + let response = self + .post(&format!( + "_synapse/admin/v2/users/{encoded_mxid}/delete_devices" + )) + .json(&SynapseDeleteDevicesRequest { devices: to_delete }) + .send_traced() + .await + .context("Failed to delete devices from Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while deleting devices from Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while deleting devices from Synapse: {}", + response.status() + ); + } + + // Then, create the devices that are missing. There is no batching API to do + // this, so we do this sequentially, which is fine as the API is idempotent. + for device_id in devices.difference(&existing_devices) { + self.upsert_device(localpart, device_id, None).await?; + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.delete_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + erase = erase, + ), + err(Debug), + )] + async fn delete_user(&self, localpart: &str, erase: bool) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + + let response = self + .post(&format!("_synapse/admin/v1/deactivate/{encoded_mxid}")) + .json(&SynapseDeactivateUserRequest { erase }) + // Deactivation can take a while, so we set a longer timeout + .timeout(Duration::from_secs(60 * 5)) + .send_traced() + .await + .context("Failed to deactivate user in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while deactivating user in Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while deactivating user in Synapse: {}", + response.status() + ); + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.reactivate_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn reactivate_user(&self, localpart: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + let response = self + .put(&format!("_synapse/admin/v2/users/{encoded_mxid}")) + .json(&SynapseUser { + deactivated: Some(false), + ..SynapseUser::default() + }) + .send_traced() + .await + .context("Failed to reactivate user in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while reactivating user in Synapse")?; + + match response.status() { + StatusCode::CREATED | StatusCode::OK => Ok(()), + code => bail!("Unexpected HTTP code while reactivating user in Synapse: {code}",), + } + } + + #[tracing::instrument( + name = "homeserver.set_displayname", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.displayname = displayname, + ), + err(Debug), + )] + async fn set_displayname( + &self, + localpart: &str, + displayname: &str, + ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + let response = self + .put(&format!( + "_matrix/client/v3/profile/{encoded_mxid}/displayname" + )) + .json(&SetDisplayNameRequest { displayname }) + .send_traced() + .await + .context("Failed to set displayname in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while setting displayname in Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while setting displayname in Synapse: {}", + response.status() + ); + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.unset_displayname", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Display), + )] + async fn unset_displayname(&self, localpart: &str) -> Result<(), anyhow::Error> { + self.set_displayname(localpart, "").await + } + + #[tracing::instrument( + name = "homeserver.allow_cross_signing_reset", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn allow_cross_signing_reset(&self, localpart: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); + let encoded_mxid = urlencoding::encode(&mxid); + + let response = self + .post(&format!( + "_synapse/admin/v1/users/{encoded_mxid}/_allow_cross_signing_replacement_without_uia" + )) + .json(&SynapseAllowCrossSigningResetRequest {}) + .send_traced() + .await + .context("Failed to allow cross-signing reset in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while allowing cross-signing reset in Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while allowing cross-signing reset in Synapse: {}", + response.status(), + ); + } + + Ok(()) + } +} diff --git a/crates/matrix-synapse/src/lib.rs b/crates/matrix-synapse/src/lib.rs index 5ca7daa9e..062ecaa75 100644 --- a/crates/matrix-synapse/src/lib.rs +++ b/crates/matrix-synapse/src/lib.rs @@ -1,677 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -use std::{collections::HashSet, time::Duration}; - -use anyhow::{Context, bail}; -use error::SynapseResponseExt; -use http::{Method, StatusCode}; -use mas_http::RequestBuilderExt as _; -use mas_matrix::{HomeserverConnection, MatrixUser, ProvisionRequest}; -use serde::{Deserialize, Serialize}; -use tracing::debug; -use url::Url; - -static SYNAPSE_AUTH_PROVIDER: &str = "oauth-delegated"; - -/// Encountered when trying to register a user ID which has been taken. -/// — -const M_USER_IN_USE: &str = "M_USER_IN_USE"; -/// Encountered when trying to register a user ID which is not valid. -/// — -const M_INVALID_USERNAME: &str = "M_INVALID_USERNAME"; +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod error; +mod legacy; +mod modern; -#[derive(Clone)] -pub struct SynapseConnection { - homeserver: String, - endpoint: Url, - access_token: String, - http_client: reqwest::Client, -} - -impl SynapseConnection { - #[must_use] - pub fn new( - homeserver: String, - endpoint: Url, - access_token: String, - http_client: reqwest::Client, - ) -> Self { - Self { - homeserver, - endpoint, - access_token, - http_client, - } - } - - fn builder(&self, method: Method, url: &str) -> reqwest::RequestBuilder { - self.http_client - .request( - method, - self.endpoint - .join(url) - .map(String::from) - .unwrap_or_default(), - ) - .bearer_auth(&self.access_token) - } - - fn post(&self, url: &str) -> reqwest::RequestBuilder { - self.builder(Method::POST, url) - } - - fn get(&self, url: &str) -> reqwest::RequestBuilder { - self.builder(Method::GET, url) - } - - fn put(&self, url: &str) -> reqwest::RequestBuilder { - self.builder(Method::PUT, url) - } - - fn delete(&self, url: &str) -> reqwest::RequestBuilder { - self.builder(Method::DELETE, url) - } -} - -#[derive(Serialize, Deserialize)] -struct ExternalID { - auth_provider: String, - external_id: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum ThreePIDMedium { - Email, - Msisdn, -} - -#[derive(Serialize, Deserialize)] -struct ThreePID { - medium: ThreePIDMedium, - address: String, -} - -#[derive(Default, Serialize, Deserialize)] -struct SynapseUser { - #[serde( - default, - rename = "displayname", - skip_serializing_if = "Option::is_none" - )] - display_name: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - avatar_url: Option, - - #[serde(default, rename = "threepids", skip_serializing_if = "Option::is_none")] - three_pids: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - external_ids: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - deactivated: Option, -} - -#[derive(Deserialize)] -struct SynapseDeviceListResponse { - devices: Vec, -} - -#[derive(Serialize, Deserialize)] -struct SynapseDevice { - device_id: String, - - #[serde(default, skip_serializing_if = "Option::is_none")] - dehydrated: Option, -} - -#[derive(Serialize)] -struct SynapseUpdateDeviceRequest<'a> { - display_name: Option<&'a str>, -} - -#[derive(Serialize)] -struct SynapseDeleteDevicesRequest { - devices: Vec, -} - -#[derive(Serialize)] -struct SetDisplayNameRequest<'a> { - displayname: &'a str, -} - -#[derive(Serialize)] -struct SynapseDeactivateUserRequest { - erase: bool, -} - -#[derive(Serialize)] -struct SynapseAllowCrossSigningResetRequest {} - -/// Response body of -/// `/_synapse/admin/v1/username_available?username={localpart}` -#[derive(Deserialize)] -struct UsernameAvailableResponse { - available: bool, -} - -#[async_trait::async_trait] -impl HomeserverConnection for SynapseConnection { - fn homeserver(&self) -> &str { - &self.homeserver - } - - #[tracing::instrument( - name = "homeserver.query_user", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - ), - err(Debug), - )] - async fn query_user(&self, mxid: &str) -> Result { - let encoded_mxid = urlencoding::encode(mxid); - - let response = self - .get(&format!("_synapse/admin/v2/users/{encoded_mxid}")) - .send_traced() - .await - .context("Failed to query user from Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while querying user from Synapse")?; - - let body: SynapseUser = response - .json() - .await - .context("Failed to deserialize response while querying user from Synapse")?; - - Ok(MatrixUser { - displayname: body.display_name, - avatar_url: body.avatar_url, - deactivated: body.deactivated.unwrap_or(false), - }) - } - - #[tracing::instrument( - name = "homeserver.is_localpart_available", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.localpart = localpart, - ), - err(Debug), - )] - async fn is_localpart_available(&self, localpart: &str) -> Result { - // Synapse will give us a M_UNKNOWN error if the localpart is not ASCII, - // so we bail out early - if !localpart.is_ascii() { - return Ok(false); - } - - let localpart = urlencoding::encode(localpart); - - let response = self - .get(&format!( - "_synapse/admin/v1/username_available?username={localpart}" - )) - .send_traced() - .await - .context("Failed to query localpart availability from Synapse")?; - - match response.error_for_synapse_error().await { - Ok(resp) => { - let response: UsernameAvailableResponse = resp.json().await.context( - "Unexpected response while querying localpart availability from Synapse", - )?; - - Ok(response.available) - } - - Err(err) - if err.errcode() == Some(M_INVALID_USERNAME) - || err.errcode() == Some(M_USER_IN_USE) => - { - debug!( - error = &err as &dyn std::error::Error, - "Localpart is not available" - ); - Ok(false) - } - - Err(err) => Err(err).context("Failed to query localpart availability from Synapse"), - } - } - - #[tracing::instrument( - name = "homeserver.provision_user", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = request.mxid(), - user.id = request.sub(), - ), - err(Debug), - )] - async fn provision_user(&self, request: &ProvisionRequest) -> Result { - let mut body = SynapseUser { - external_ids: Some(vec![ExternalID { - auth_provider: SYNAPSE_AUTH_PROVIDER.to_owned(), - external_id: request.sub().to_owned(), - }]), - ..SynapseUser::default() - }; - - request - .on_displayname(|displayname| { - body.display_name = Some(displayname.unwrap_or_default().to_owned()); - }) - .on_avatar_url(|avatar_url| { - body.avatar_url = Some(avatar_url.unwrap_or_default().to_owned()); - }) - .on_emails(|emails| { - body.three_pids = Some( - emails - .unwrap_or_default() - .iter() - .map(|email| ThreePID { - medium: ThreePIDMedium::Email, - address: email.clone(), - }) - .collect(), - ); - }); - - let encoded_mxid = urlencoding::encode(request.mxid()); - let response = self - .put(&format!("_synapse/admin/v2/users/{encoded_mxid}")) - .json(&body) - .send_traced() - .await - .context("Failed to provision user in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while provisioning user in Synapse")?; - - match response.status() { - StatusCode::CREATED => Ok(true), - StatusCode::OK => Ok(false), - code => bail!("Unexpected HTTP code while provisioning user in Synapse: {code}"), - } - } - - #[tracing::instrument( - name = "homeserver.create_device", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - matrix.device_id = device_id, - ), - err(Debug), - )] - async fn create_device( - &self, - mxid: &str, - device_id: &str, - initial_display_name: Option<&str>, - ) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - - let response = self - .post(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) - .json(&SynapseDevice { - device_id: device_id.to_owned(), - dehydrated: None, - }) - .send_traced() - .await - .context("Failed to create device in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while creating device in Synapse")?; - - if response.status() != StatusCode::CREATED { - bail!( - "Unexpected HTTP code while creating device in Synapse: {}", - response.status() - ); - } - - // It's annoying, but the POST endpoint doesn't let us set the display name - // of the device, so we have to do it manually. - if let Some(display_name) = initial_display_name { - self.update_device_display_name(mxid, device_id, display_name) - .await?; - } - - Ok(()) - } - - #[tracing::instrument( - name = "homeserver.update_device_display_name", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - matrix.device_id = device_id, - ), - err(Debug), - )] - async fn update_device_display_name( - &self, - mxid: &str, - device_id: &str, - display_name: &str, - ) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - let device_id = urlencoding::encode(device_id); - let response = self - .put(&format!( - "_synapse/admin/v2/users/{encoded_mxid}/devices/{device_id}" - )) - .json(&SynapseUpdateDeviceRequest { - display_name: Some(display_name), - }) - .send_traced() - .await - .context("Failed to update device display name in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while updating device display name in Synapse")?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while updating device display name in Synapse: {}", - response.status() - ); - } - - Ok(()) - } - - #[tracing::instrument( - name = "homeserver.delete_device", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - matrix.device_id = device_id, - ), - err(Debug), - )] - async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - let encoded_device_id = urlencoding::encode(device_id); - - let response = self - .delete(&format!( - "_synapse/admin/v2/users/{encoded_mxid}/devices/{encoded_device_id}" - )) - .send_traced() - .await - .context("Failed to delete device in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while deleting device in Synapse")?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while deleting device in Synapse: {}", - response.status() - ); - } - - Ok(()) - } - - #[tracing::instrument( - name = "homeserver.sync_devices", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - ), - err(Debug), - )] - async fn sync_devices( - &self, - mxid: &str, - devices: HashSet, - ) -> Result<(), anyhow::Error> { - // Get the list of current devices - let encoded_mxid = urlencoding::encode(mxid); - - let response = self - .get(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) - .send_traced() - .await - .context("Failed to query devices from Synapse")?; - - let response = response.error_for_synapse_error().await?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while querying devices from Synapse: {}", - response.status() - ); - } - - let body: SynapseDeviceListResponse = response - .json() - .await - .context("Failed to parse response while querying devices from Synapse")?; - - let existing_devices: HashSet = body - .devices - .into_iter() - .filter(|d| d.dehydrated != Some(true)) - .map(|d| d.device_id) - .collect(); - - // First, delete all the devices that are not needed anymore - let to_delete = existing_devices.difference(&devices).cloned().collect(); - - let response = self - .post(&format!( - "_synapse/admin/v2/users/{encoded_mxid}/delete_devices" - )) - .json(&SynapseDeleteDevicesRequest { devices: to_delete }) - .send_traced() - .await - .context("Failed to delete devices from Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while deleting devices from Synapse")?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while deleting devices from Synapse: {}", - response.status() - ); - } - - // Then, create the devices that are missing. There is no batching API to do - // this, so we do this sequentially, which is fine as the API is idempotent. - for device_id in devices.difference(&existing_devices) { - self.create_device(mxid, device_id, None).await?; - } - - Ok(()) - } - - #[tracing::instrument( - name = "homeserver.delete_user", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - erase = erase, - ), - err(Debug), - )] - async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - - let response = self - .post(&format!("_synapse/admin/v1/deactivate/{encoded_mxid}")) - .json(&SynapseDeactivateUserRequest { erase }) - // Deactivation can take a while, so we set a longer timeout - .timeout(Duration::from_secs(60 * 5)) - .send_traced() - .await - .context("Failed to deactivate user in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while deactivating user in Synapse")?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while deactivating user in Synapse: {}", - response.status() - ); - } - - Ok(()) - } - - #[tracing::instrument( - name = "homeserver.reactivate_user", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - ), - err(Debug), - )] - async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - let response = self - .put(&format!("_synapse/admin/v2/users/{encoded_mxid}")) - .json(&SynapseUser { - deactivated: Some(false), - ..SynapseUser::default() - }) - .send_traced() - .await - .context("Failed to reactivate user in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while reactivating user in Synapse")?; - - match response.status() { - StatusCode::CREATED | StatusCode::OK => Ok(()), - code => bail!("Unexpected HTTP code while reactivating user in Synapse: {code}",), - } - } - - #[tracing::instrument( - name = "homeserver.set_displayname", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - matrix.displayname = displayname, - ), - err(Debug), - )] - async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - let response = self - .put(&format!( - "_matrix/client/v3/profile/{encoded_mxid}/displayname" - )) - .json(&SetDisplayNameRequest { displayname }) - .send_traced() - .await - .context("Failed to set displayname in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while setting displayname in Synapse")?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while setting displayname in Synapse: {}", - response.status() - ); - } - - Ok(()) - } - - #[tracing::instrument( - name = "homeserver.unset_displayname", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - ), - err(Display), - )] - async fn unset_displayname(&self, mxid: &str) -> Result<(), anyhow::Error> { - self.set_displayname(mxid, "").await - } - - #[tracing::instrument( - name = "homeserver.allow_cross_signing_reset", - skip_all, - fields( - matrix.homeserver = self.homeserver, - matrix.mxid = mxid, - ), - err(Debug), - )] - async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error> { - let encoded_mxid = urlencoding::encode(mxid); - - let response = self - .post(&format!( - "_synapse/admin/v1/users/{encoded_mxid}/_allow_cross_signing_replacement_without_uia" - )) - .json(&SynapseAllowCrossSigningResetRequest {}) - .send_traced() - .await - .context("Failed to allow cross-signing reset in Synapse")?; - - let response = response - .error_for_synapse_error() - .await - .context("Unexpected HTTP response while allowing cross-signing reset in Synapse")?; - - if response.status() != StatusCode::OK { - bail!( - "Unexpected HTTP code while allowing cross-signing reset in Synapse: {}", - response.status(), - ); - } - - Ok(()) - } -} +pub use self::{legacy::SynapseConnection as LegacySynapseConnection, modern::SynapseConnection}; diff --git a/crates/matrix-synapse/src/modern.rs b/crates/matrix-synapse/src/modern.rs new file mode 100644 index 000000000..3d70f52de --- /dev/null +++ b/crates/matrix-synapse/src/modern.rs @@ -0,0 +1,567 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::collections::HashSet; + +use anyhow::Context as _; +use http::{Method, StatusCode}; +use mas_http::RequestBuilderExt; +use mas_matrix::{HomeserverConnection, MatrixUser, ProvisionRequest}; +use serde::{Deserialize, Serialize}; +use tracing::debug; +use url::Url; + +use crate::error::{M_EXCLUSIVE, M_INVALID_USERNAME, M_USER_IN_USE, SynapseResponseExt as _}; + +#[derive(Clone)] +pub struct SynapseConnection { + homeserver: String, + endpoint: Url, + access_token: String, + http_client: reqwest::Client, +} + +impl SynapseConnection { + #[must_use] + pub fn new( + homeserver: String, + endpoint: Url, + access_token: String, + http_client: reqwest::Client, + ) -> Self { + Self { + homeserver, + endpoint, + access_token, + http_client, + } + } + + fn builder(&self, method: Method, url: &str) -> reqwest::RequestBuilder { + self.http_client + .request( + method, + self.endpoint + .join(url) + .map(String::from) + .unwrap_or_default(), + ) + .bearer_auth(&self.access_token) + } + + fn post(&self, url: &str) -> reqwest::RequestBuilder { + self.builder(Method::POST, url) + } + + fn get(&self, url: &str) -> reqwest::RequestBuilder { + self.builder(Method::GET, url) + } +} + +#[async_trait::async_trait] +impl HomeserverConnection for SynapseConnection { + fn homeserver(&self) -> &str { + &self.homeserver + } + + #[tracing::instrument(name = "homeserver.verify_token", skip_all, err(Debug))] + async fn verify_token(&self, token: &str) -> Result { + Ok(self.access_token == token) + } + + #[tracing::instrument( + name = "homeserver.query_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn query_user(&self, localpart: &str) -> Result { + #[derive(Deserialize)] + #[allow(dead_code)] + struct Response { + user_id: String, + display_name: Option, + avatar_url: Option, + is_suspended: bool, + is_deactivated: bool, + } + + let encoded_localpart = urlencoding::encode(localpart); + let url = format!("_synapse/mas/query_user?localpart={encoded_localpart}"); + let response = self + .get(&url) + .send_traced() + .await + .context("Failed to query user from Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while querying user from Synapse")?; + + let body: Response = response + .json() + .await + .context("Failed to deserialize response while querying user from Synapse")?; + + Ok(MatrixUser { + displayname: body.display_name, + avatar_url: body.avatar_url, + deactivated: body.is_deactivated, + }) + } + + #[tracing::instrument( + name = "homeserver.provision_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = request.localpart(), + ), + err(Debug), + )] + async fn provision_user(&self, request: &ProvisionRequest) -> Result { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + set_displayname: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + unset_displayname: bool, + #[serde(skip_serializing_if = "Option::is_none")] + set_avatar_url: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + unset_avatar_url: bool, + #[serde(skip_serializing_if = "Option::is_none")] + set_emails: Option>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + unset_emails: bool, + } + + let mut body = Request { + localpart: request.localpart(), + set_displayname: None, + unset_displayname: false, + set_avatar_url: None, + unset_avatar_url: false, + set_emails: None, + unset_emails: false, + }; + + request.on_displayname(|displayname| match displayname { + Some(name) => body.set_displayname = Some(name.to_owned()), + None => body.unset_displayname = true, + }); + + request.on_avatar_url(|avatar_url| match avatar_url { + Some(url) => body.set_avatar_url = Some(url.to_owned()), + None => body.unset_avatar_url = true, + }); + + request.on_emails(|emails| match emails { + Some(emails) => body.set_emails = Some(emails.to_owned()), + None => body.unset_emails = true, + }); + + let response = self + .post("_synapse/mas/provision_user") + .json(&body) + .send_traced() + .await + .context("Failed to provision user in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while provisioning user in Synapse")?; + + match response.status() { + StatusCode::CREATED => Ok(true), + StatusCode::OK => Ok(false), + code => { + anyhow::bail!("Unexpected HTTP code while provisioning user in Synapse: {code}") + } + } + } + + #[tracing::instrument( + name = "homeserver.is_localpart_available", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn is_localpart_available(&self, localpart: &str) -> Result { + // Synapse will give us an error if the localpart is not ASCII, so we bail out + // early + if !localpart.is_ascii() { + return Ok(false); + } + + let encoded_localpart = urlencoding::encode(localpart); + let url = format!("_synapse/mas/is_localpart_available?localpart={encoded_localpart}"); + let response = self + .get(&url) + .send_traced() + .await + .context("Failed to check localpart availability from Synapse")?; + + match response.error_for_synapse_error().await { + Ok(_resp) => Ok(true), + Err(err) + if err.errcode() == Some(M_INVALID_USERNAME) + || err.errcode() == Some(M_USER_IN_USE) + || err.errcode() == Some(M_EXCLUSIVE) => + { + debug!( + error = &err as &dyn std::error::Error, + "Localpart is not available" + ); + Ok(false) + } + + Err(err) => Err(err).context("Failed to query localpart availability from Synapse"), + } + } + + #[tracing::instrument( + name = "homeserver.upsert_device", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn upsert_device( + &self, + localpart: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + device_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + display_name: Option<&'a str>, + } + + let body = Request { + localpart, + device_id, + display_name: initial_display_name, + }; + + let response = self + .post("_synapse/mas/upsert_device") + .json(&body) + .send_traced() + .await + .context("Failed to create device in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while creating device in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.update_device_display_name", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn update_device_display_name( + &self, + localpart: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + device_id: &'a str, + display_name: &'a str, + } + + let body = Request { + localpart, + device_id, + display_name, + }; + + let response = self + .post("_synapse/mas/update_device_display_name") + .json(&body) + .send_traced() + .await + .context("Failed to update device display name in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while updating device display name in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.delete_device", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn delete_device(&self, localpart: &str, device_id: &str) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + device_id: &'a str, + } + + let body = Request { + localpart, + device_id, + }; + + let response = self + .post("_synapse/mas/delete_device") + .json(&body) + .send_traced() + .await + .context("Failed to delete device in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while deleting device in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.sync_devices", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.device_count = devices.len(), + ), + err(Debug), + )] + async fn sync_devices( + &self, + localpart: &str, + devices: HashSet, + ) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + devices: HashSet, + } + + let body = Request { localpart, devices }; + + let response = self + .post("_synapse/mas/sync_devices") + .json(&body) + .send_traced() + .await + .context("Failed to sync devices in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while syncing devices in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.delete_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + matrix.erase = erase, + ), + err(Debug), + )] + async fn delete_user(&self, localpart: &str, erase: bool) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + erase: bool, + } + + let body = Request { localpart, erase }; + + let response = self + .post("_synapse/mas/delete_user") + .json(&body) + .send_traced() + .await + .context("Failed to delete user in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while deleting user in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.reactivate_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn reactivate_user(&self, localpart: &str) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + } + + let body = Request { localpart }; + + let response = self + .post("_synapse/mas/reactivate_user") + .json(&body) + .send_traced() + .await + .context("Failed to reactivate user in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while reactivating user in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.set_displayname", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn set_displayname( + &self, + localpart: &str, + displayname: &str, + ) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + displayname: &'a str, + } + + let body = Request { + localpart, + displayname, + }; + + let response = self + .post("_synapse/mas/set_displayname") + .json(&body) + .send_traced() + .await + .context("Failed to set displayname in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while setting displayname in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.unset_displayname", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn unset_displayname(&self, localpart: &str) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + } + + let body = Request { localpart }; + + let response = self + .post("_synapse/mas/unset_displayname") + .json(&body) + .send_traced() + .await + .context("Failed to unset displayname in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while unsetting displayname in Synapse")?; + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.allow_cross_signing_reset", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.localpart = localpart, + ), + err(Debug), + )] + async fn allow_cross_signing_reset(&self, localpart: &str) -> Result<(), anyhow::Error> { + #[derive(Serialize)] + struct Request<'a> { + localpart: &'a str, + } + + let body = Request { localpart }; + + let response = self + .post("_synapse/mas/allow_cross_signing_reset") + .json(&body) + .send_traced() + .await + .context("Failed to allow cross-signing reset in Synapse")?; + + response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while allowing cross-signing reset in Synapse")?; + + Ok(()) + } +} diff --git a/crates/matrix/Cargo.toml b/crates/matrix/Cargo.toml index 2182c6426..a041fee5e 100644 --- a/crates/matrix/Cargo.toml +++ b/crates/matrix/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-matrix" version.workspace = true diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index ae8a4e563..f1fbe9c83 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod mock; mod readonly; @@ -31,7 +31,7 @@ enum FieldAction { } pub struct ProvisionRequest { - mxid: String, + localpart: String, sub: String, displayname: FieldAction, avatar_url: FieldAction, @@ -43,12 +43,12 @@ impl ProvisionRequest { /// /// # Parameters /// - /// * `mxid` - The Matrix ID to provision. + /// * `localpart` - The localpart of the user to provision. /// * `sub` - The `sub` of the user, aka the internal ID. #[must_use] - pub fn new(mxid: impl Into, sub: impl Into) -> Self { + pub fn new(localpart: impl Into, sub: impl Into) -> Self { Self { - mxid: mxid.into(), + localpart: localpart.into(), sub: sub.into(), displayname: FieldAction::DoNothing, avatar_url: FieldAction::DoNothing, @@ -62,10 +62,10 @@ impl ProvisionRequest { &self.sub } - /// Get the Matrix ID to provision. + /// Get the localpart of the user to provision. #[must_use] - pub fn mxid(&self) -> &str { - &self.mxid + pub fn localpart(&self) -> &str { + &self.localpart } /// Ask to set the displayname of the user. @@ -207,17 +207,31 @@ pub trait HomeserverConnection: Send + Sync { Some(mxid.localpart()) } + /// Verify a bearer token coming from the homeserver for homeserver to MAS + /// interactions + /// + /// Returns `true` if the token is valid, `false` otherwise. + /// + /// # Parameters + /// + /// * `token` - The token to verify. + /// + /// # Errors + /// + /// Returns an error if the token failed to verify. + async fn verify_token(&self, token: &str) -> Result; + /// Query the state of a user on the homeserver. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to query. + /// * `localpart` - The localpart of the user to query. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the user does not /// exist. - async fn query_user(&self, mxid: &str) -> Result; + async fn query_user(&self, localpart: &str) -> Result; /// Provision a user on the homeserver. /// @@ -247,16 +261,16 @@ pub trait HomeserverConnection: Send + Sync { /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to create a device for. + /// * `localpart` - The localpart of the user to create a device for. /// * `device_id` - The device ID to create. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the device could /// not be created. - async fn create_device( + async fn upsert_device( &self, - mxid: &str, + localpart: &str, device_id: &str, initial_display_name: Option<&str>, ) -> Result<(), anyhow::Error>; @@ -265,7 +279,7 @@ pub trait HomeserverConnection: Send + Sync { /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to update a device for. + /// * `localpart` - The localpart of the user to update a device for. /// * `device_id` - The device ID to update. /// * `display_name` - The new display name to set /// @@ -275,7 +289,7 @@ pub trait HomeserverConnection: Send + Sync { /// not be updated. async fn update_device_display_name( &self, - mxid: &str, + localpart: &str, device_id: &str, display_name: &str, ) -> Result<(), anyhow::Error>; @@ -284,90 +298,98 @@ pub trait HomeserverConnection: Send + Sync { /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to delete a device for. + /// * `localpart` - The localpart of the user to delete a device for. /// * `device_id` - The device ID to delete. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the device could /// not be deleted. - async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error>; + async fn delete_device(&self, localpart: &str, device_id: &str) -> Result<(), anyhow::Error>; /// Sync the list of devices of a user with the homeserver. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to sync the devices for. + /// * `localpart` - The localpart of the user to sync the devices for. /// * `devices` - The list of devices to sync. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the devices could /// not be synced. - async fn sync_devices(&self, mxid: &str, devices: HashSet) - -> Result<(), anyhow::Error>; + async fn sync_devices( + &self, + localpart: &str, + devices: HashSet, + ) -> Result<(), anyhow::Error>; /// Delete a user on the homeserver. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to delete. + /// * `localpart` - The localpart of the user to delete. /// * `erase` - Whether to ask the homeserver to erase the user's data. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the user could not /// be deleted. - async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error>; + async fn delete_user(&self, localpart: &str, erase: bool) -> Result<(), anyhow::Error>; /// Reactivate a user on the homeserver. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to reactivate. + /// * `localpart` - The localpart of the user to reactivate. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the user could not /// be reactivated. - async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error>; + async fn reactivate_user(&self, localpart: &str) -> Result<(), anyhow::Error>; /// Set the displayname of a user on the homeserver. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to set the displayname for. + /// * `localpart` - The localpart of the user to set the displayname for. /// * `displayname` - The displayname to set. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the displayname /// could not be set. - async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error>; + async fn set_displayname( + &self, + localpart: &str, + displayname: &str, + ) -> Result<(), anyhow::Error>; /// Unset the displayname of a user on the homeserver. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to unset the displayname for. + /// * `localpart` - The localpart of the user to unset the displayname for. /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the displayname /// could not be unset. - async fn unset_displayname(&self, mxid: &str) -> Result<(), anyhow::Error>; + async fn unset_displayname(&self, localpart: &str) -> Result<(), anyhow::Error>; /// Temporarily allow a user to reset their cross-signing keys. /// /// # Parameters /// - /// * `mxid` - The Matrix ID of the user to allow cross-signing key reset + /// * `localpart` - The localpart of the user to allow cross-signing key + /// reset /// /// # Errors /// /// Returns an error if the homeserver is unreachable or the cross-signing /// reset could not be allowed. - async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error>; + async fn allow_cross_signing_reset(&self, localpart: &str) -> Result<(), anyhow::Error>; } #[async_trait::async_trait] @@ -376,8 +398,12 @@ impl HomeserverConnection for &T (**self).homeserver() } - async fn query_user(&self, mxid: &str) -> Result { - (**self).query_user(mxid).await + async fn verify_token(&self, token: &str) -> Result { + (**self).verify_token(token).await + } + + async fn query_user(&self, localpart: &str) -> Result { + (**self).query_user(localpart).await } async fn provision_user(&self, request: &ProvisionRequest) -> Result { @@ -388,58 +414,62 @@ impl HomeserverConnection for &T (**self).is_localpart_available(localpart).await } - async fn create_device( + async fn upsert_device( &self, - mxid: &str, + localpart: &str, device_id: &str, initial_display_name: Option<&str>, ) -> Result<(), anyhow::Error> { (**self) - .create_device(mxid, device_id, initial_display_name) + .upsert_device(localpart, device_id, initial_display_name) .await } async fn update_device_display_name( &self, - mxid: &str, + localpart: &str, device_id: &str, display_name: &str, ) -> Result<(), anyhow::Error> { (**self) - .update_device_display_name(mxid, device_id, display_name) + .update_device_display_name(localpart, device_id, display_name) .await } - async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - (**self).delete_device(mxid, device_id).await + async fn delete_device(&self, localpart: &str, device_id: &str) -> Result<(), anyhow::Error> { + (**self).delete_device(localpart, device_id).await } async fn sync_devices( &self, - mxid: &str, + localpart: &str, devices: HashSet, ) -> Result<(), anyhow::Error> { - (**self).sync_devices(mxid, devices).await + (**self).sync_devices(localpart, devices).await } - async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error> { - (**self).delete_user(mxid, erase).await + async fn delete_user(&self, localpart: &str, erase: bool) -> Result<(), anyhow::Error> { + (**self).delete_user(localpart, erase).await } - async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> { - (**self).reactivate_user(mxid).await + async fn reactivate_user(&self, localpart: &str) -> Result<(), anyhow::Error> { + (**self).reactivate_user(localpart).await } - async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error> { - (**self).set_displayname(mxid, displayname).await + async fn set_displayname( + &self, + localpart: &str, + displayname: &str, + ) -> Result<(), anyhow::Error> { + (**self).set_displayname(localpart, displayname).await } - async fn unset_displayname(&self, mxid: &str) -> Result<(), anyhow::Error> { - (**self).unset_displayname(mxid).await + async fn unset_displayname(&self, localpart: &str) -> Result<(), anyhow::Error> { + (**self).unset_displayname(localpart).await } - async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error> { - (**self).allow_cross_signing_reset(mxid).await + async fn allow_cross_signing_reset(&self, localpart: &str) -> Result<(), anyhow::Error> { + (**self).allow_cross_signing_reset(localpart).await } } @@ -450,8 +480,12 @@ impl HomeserverConnection for Arc { (**self).homeserver() } - async fn query_user(&self, mxid: &str) -> Result { - (**self).query_user(mxid).await + async fn verify_token(&self, token: &str) -> Result { + (**self).verify_token(token).await + } + + async fn query_user(&self, localpart: &str) -> Result { + (**self).query_user(localpart).await } async fn provision_user(&self, request: &ProvisionRequest) -> Result { @@ -462,57 +496,61 @@ impl HomeserverConnection for Arc { (**self).is_localpart_available(localpart).await } - async fn create_device( + async fn upsert_device( &self, - mxid: &str, + localpart: &str, device_id: &str, initial_display_name: Option<&str>, ) -> Result<(), anyhow::Error> { (**self) - .create_device(mxid, device_id, initial_display_name) + .upsert_device(localpart, device_id, initial_display_name) .await } async fn update_device_display_name( &self, - mxid: &str, + localpart: &str, device_id: &str, display_name: &str, ) -> Result<(), anyhow::Error> { (**self) - .update_device_display_name(mxid, device_id, display_name) + .update_device_display_name(localpart, device_id, display_name) .await } - async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - (**self).delete_device(mxid, device_id).await + async fn delete_device(&self, localpart: &str, device_id: &str) -> Result<(), anyhow::Error> { + (**self).delete_device(localpart, device_id).await } async fn sync_devices( &self, - mxid: &str, + localpart: &str, devices: HashSet, ) -> Result<(), anyhow::Error> { - (**self).sync_devices(mxid, devices).await + (**self).sync_devices(localpart, devices).await } - async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error> { - (**self).delete_user(mxid, erase).await + async fn delete_user(&self, localpart: &str, erase: bool) -> Result<(), anyhow::Error> { + (**self).delete_user(localpart, erase).await } - async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> { - (**self).reactivate_user(mxid).await + async fn reactivate_user(&self, localpart: &str) -> Result<(), anyhow::Error> { + (**self).reactivate_user(localpart).await } - async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error> { - (**self).set_displayname(mxid, displayname).await + async fn set_displayname( + &self, + localpart: &str, + displayname: &str, + ) -> Result<(), anyhow::Error> { + (**self).set_displayname(localpart, displayname).await } - async fn unset_displayname(&self, mxid: &str) -> Result<(), anyhow::Error> { - (**self).unset_displayname(mxid).await + async fn unset_displayname(&self, localpart: &str) -> Result<(), anyhow::Error> { + (**self).unset_displayname(localpart).await } - async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error> { - (**self).allow_cross_signing_reset(mxid).await + async fn allow_cross_signing_reset(&self, localpart: &str) -> Result<(), anyhow::Error> { + (**self).allow_cross_signing_reset(localpart).await } } diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 7c7973ce0..4180597e2 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::{HashMap, HashSet}; @@ -31,6 +31,10 @@ pub struct HomeserverConnection { } impl HomeserverConnection { + /// A valid bearer token that will be accepted by + /// [`crate::HomeserverConnection::verify_token`]. + pub const VALID_BEARER_TOKEN: &str = "mock_homeserver_bearer_token"; + /// Create a new mock connection. pub fn new(homeserver: H) -> Self where @@ -54,9 +58,14 @@ impl crate::HomeserverConnection for HomeserverConnection { &self.homeserver } - async fn query_user(&self, mxid: &str) -> Result { + async fn verify_token(&self, token: &str) -> Result { + Ok(token == Self::VALID_BEARER_TOKEN) + } + + async fn query_user(&self, localpart: &str) -> Result { + let mxid = self.mxid(localpart); let users = self.users.read().await; - let user = users.get(mxid).context("User not found")?; + let user = users.get(&mxid).context("User not found")?; Ok(MatrixUser { displayname: user.displayname.clone(), avatar_url: user.avatar_url.clone(), @@ -66,8 +75,9 @@ impl crate::HomeserverConnection for HomeserverConnection { async fn provision_user(&self, request: &ProvisionRequest) -> Result { let mut users = self.users.write().await; - let inserted = !users.contains_key(request.mxid()); - let user = users.entry(request.mxid().to_owned()).or_insert(MockUser { + let mxid = self.mxid(request.localpart()); + let inserted = !users.contains_key(&mxid); + let user = users.entry(mxid).or_insert(MockUser { sub: request.sub().to_owned(), avatar_url: None, displayname: None, @@ -107,51 +117,56 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(!users.contains_key(&mxid)) } - async fn create_device( + async fn upsert_device( &self, - mxid: &str, + localpart: &str, device_id: &str, _initial_display_name: Option<&str>, ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.devices.insert(device_id.to_owned()); Ok(()) } async fn update_device_display_name( &self, - mxid: &str, + localpart: &str, device_id: &str, _display_name: &str, ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.devices.get(device_id).context("Device not found")?; Ok(()) } - async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { + async fn delete_device(&self, localpart: &str, device_id: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.devices.remove(device_id); Ok(()) } async fn sync_devices( &self, - mxid: &str, + localpart: &str, devices: HashSet, ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.devices = devices; Ok(()) } - async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error> { + async fn delete_user(&self, localpart: &str, erase: bool) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.devices.clear(); user.emails = None; user.deactivated = true; @@ -163,31 +178,39 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(()) } - async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> { + async fn reactivate_user(&self, localpart: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.deactivated = false; Ok(()) } - async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error> { + async fn set_displayname( + &self, + localpart: &str, + displayname: &str, + ) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.displayname = Some(displayname.to_owned()); Ok(()) } - async fn unset_displayname(&self, mxid: &str) -> Result<(), anyhow::Error> { + async fn unset_displayname(&self, localpart: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.displayname = None; Ok(()) } - async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error> { + async fn allow_cross_signing_reset(&self, localpart: &str) -> Result<(), anyhow::Error> { + let mxid = self.mxid(localpart); let mut users = self.users.write().await; - let user = users.get_mut(mxid).context("User not found")?; + let user = users.get_mut(&mxid).context("User not found")?; user.cross_signing_reset_allowed = true; Ok(()) } @@ -207,11 +230,11 @@ mod tests { assert_eq!(conn.homeserver(), "example.org"); assert_eq!(conn.mxid("test"), mxid); - assert!(conn.query_user(mxid).await.is_err()); - assert!(conn.create_device(mxid, device, None).await.is_err()); - assert!(conn.delete_device(mxid, device).await.is_err()); + assert!(conn.query_user("test").await.is_err()); + assert!(conn.upsert_device("test", device, None).await.is_err()); + assert!(conn.delete_device("test", device).await.is_err()); - let request = ProvisionRequest::new("@test:example.org", "test") + let request = ProvisionRequest::new("test", "test") .set_displayname("Test User".into()) .set_avatar_url("mxc://example.org/1234567890".into()) .set_emails(vec!["test@example.org".to_owned()]); @@ -219,33 +242,33 @@ mod tests { let inserted = conn.provision_user(&request).await.unwrap(); assert!(inserted); - let user = conn.query_user(mxid).await.unwrap(); + let user = conn.query_user("test").await.unwrap(); assert_eq!(user.displayname, Some("Test User".into())); assert_eq!(user.avatar_url, Some("mxc://example.org/1234567890".into())); // Set the displayname again - assert!(conn.set_displayname(mxid, "John").await.is_ok()); + assert!(conn.set_displayname("test", "John").await.is_ok()); - let user = conn.query_user(mxid).await.unwrap(); + let user = conn.query_user("test").await.unwrap(); assert_eq!(user.displayname, Some("John".into())); // Unset the displayname - assert!(conn.unset_displayname(mxid).await.is_ok()); + assert!(conn.unset_displayname("test").await.is_ok()); - let user = conn.query_user(mxid).await.unwrap(); + let user = conn.query_user("test").await.unwrap(); assert_eq!(user.displayname, None); // Deleting a non-existent device should not fail - assert!(conn.delete_device(mxid, device).await.is_ok()); + assert!(conn.delete_device("test", device).await.is_ok()); // Create the device - assert!(conn.create_device(mxid, device, None).await.is_ok()); + assert!(conn.upsert_device("test", device, None).await.is_ok()); // Create the same device again - assert!(conn.create_device(mxid, device, None).await.is_ok()); + assert!(conn.upsert_device("test", device, None).await.is_ok()); // XXX: there is no API to query devices yet in the trait // Delete the device - assert!(conn.delete_device(mxid, device).await.is_ok()); + assert!(conn.delete_device("test", device).await.is_ok()); // The user we just created should be not available assert!(!conn.is_localpart_available("test").await.unwrap()); diff --git a/crates/matrix/src/readonly.rs b/crates/matrix/src/readonly.rs index 530c3cd89..590583bf8 100644 --- a/crates/matrix/src/readonly.rs +++ b/crates/matrix/src/readonly.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashSet; @@ -28,8 +28,12 @@ impl HomeserverConnection for ReadOnlyHomeserverConnect self.inner.homeserver() } - async fn query_user(&self, mxid: &str) -> Result { - self.inner.query_user(mxid).await + async fn verify_token(&self, token: &str) -> Result { + self.inner.verify_token(token).await + } + + async fn query_user(&self, localpart: &str) -> Result { + self.inner.query_user(localpart).await } async fn provision_user(&self, _request: &ProvisionRequest) -> Result { @@ -40,9 +44,9 @@ impl HomeserverConnection for ReadOnlyHomeserverConnect self.inner.is_localpart_available(localpart).await } - async fn create_device( + async fn upsert_device( &self, - _mxid: &str, + _localpart: &str, _device_id: &str, _initial_display_name: Option<&str>, ) -> Result<(), anyhow::Error> { @@ -51,42 +55,46 @@ impl HomeserverConnection for ReadOnlyHomeserverConnect async fn update_device_display_name( &self, - _mxid: &str, + _localpart: &str, _device_id: &str, _display_name: &str, ) -> Result<(), anyhow::Error> { anyhow::bail!("Device display name update is not supported in read-only mode"); } - async fn delete_device(&self, _mxid: &str, _device_id: &str) -> Result<(), anyhow::Error> { + async fn delete_device(&self, _localpart: &str, _device_id: &str) -> Result<(), anyhow::Error> { anyhow::bail!("Device deletion is not supported in read-only mode"); } async fn sync_devices( &self, - _mxid: &str, + _localpart: &str, _devices: HashSet, ) -> Result<(), anyhow::Error> { anyhow::bail!("Device synchronization is not supported in read-only mode"); } - async fn delete_user(&self, _mxid: &str, _erase: bool) -> Result<(), anyhow::Error> { + async fn delete_user(&self, _localpart: &str, _erase: bool) -> Result<(), anyhow::Error> { anyhow::bail!("User deletion is not supported in read-only mode"); } - async fn reactivate_user(&self, _mxid: &str) -> Result<(), anyhow::Error> { + async fn reactivate_user(&self, _localpart: &str) -> Result<(), anyhow::Error> { anyhow::bail!("User reactivation is not supported in read-only mode"); } - async fn set_displayname(&self, _mxid: &str, _displayname: &str) -> Result<(), anyhow::Error> { + async fn set_displayname( + &self, + _localpart: &str, + _displayname: &str, + ) -> Result<(), anyhow::Error> { anyhow::bail!("User displayname update is not supported in read-only mode"); } - async fn unset_displayname(&self, _mxid: &str) -> Result<(), anyhow::Error> { + async fn unset_displayname(&self, _localpart: &str) -> Result<(), anyhow::Error> { anyhow::bail!("User displayname update is not supported in read-only mode"); } - async fn allow_cross_signing_reset(&self, _mxid: &str) -> Result<(), anyhow::Error> { + async fn allow_cross_signing_reset(&self, _localpart: &str) -> Result<(), anyhow::Error> { anyhow::bail!("Allowing cross-signing reset is not supported in read-only mode"); } } diff --git a/crates/oauth2-types/Cargo.toml b/crates/oauth2-types/Cargo.toml index 4707bb137..2432e34f5 100644 --- a/crates/oauth2-types/Cargo.toml +++ b/crates/oauth2-types/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "oauth2-types" description = "OAuth 2.0 types used by the Matrix Authentication Service" diff --git a/crates/oauth2-types/src/errors.rs b/crates/oauth2-types/src/errors.rs index f5e349c82..b3077636c 100644 --- a/crates/oauth2-types/src/errors.rs +++ b/crates/oauth2-types/src/errors.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Error types returned by an authorization server. diff --git a/crates/oauth2-types/src/lib.rs b/crates/oauth2-types/src/lib.rs index 0f1729ec8..e937288ff 100644 --- a/crates/oauth2-types/src/lib.rs +++ b/crates/oauth2-types/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! [OAuth 2.0] and [OpenID Connect] types. //! diff --git a/crates/oauth2-types/src/oidc.rs b/crates/oauth2-types/src/oidc.rs index 5cbdf2e4b..9ac67278b 100644 --- a/crates/oauth2-types/src/oidc.rs +++ b/crates/oauth2-types/src/oidc.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Types to interact with the [OpenID Connect] specification. //! @@ -577,7 +577,7 @@ pub struct ProviderMetadata { pub require_request_uri_registration: Option, /// Indicates where authorization request needs to be protected as [Request - /// Object] and provided through either request or request_uri parameter. + /// Object] and provided through either request or `request_uri` parameter. /// /// Defaults to `false`. /// @@ -647,7 +647,10 @@ impl ProviderMetadata { let metadata = self.insecure_verify_metadata()?; if metadata.issuer() != issuer { - return Err(ProviderMetadataVerificationError::IssuerUrlsDontMatch); + return Err(ProviderMetadataVerificationError::IssuerUrlsDontMatch { + expected: issuer.to_owned(), + actual: metadata.issuer().to_owned(), + }); } validate_url( @@ -677,10 +680,10 @@ impl ProviderMetadata { validate_url("registration_endpoint", url, ExtraUrlRestrictions::None)?; } - if let Some(scopes) = &metadata.scopes_supported { - if !scopes.iter().any(|s| s == "openid") { - return Err(ProviderMetadataVerificationError::ScopesMissingOpenid); - } + if let Some(scopes) = &metadata.scopes_supported + && !scopes.iter().any(|s| s == "openid") + { + return Err(ProviderMetadataVerificationError::ScopesMissingOpenid); } validate_signing_alg_values_supported( @@ -689,10 +692,6 @@ impl ProviderMetadata { .token_endpoint_auth_signing_alg_values_supported .iter() .flatten(), - metadata - .token_endpoint_auth_methods_supported - .iter() - .flatten(), )?; if let Some(url) = &metadata.revocation_endpoint { @@ -705,33 +704,18 @@ impl ProviderMetadata { .revocation_endpoint_auth_signing_alg_values_supported .iter() .flatten(), - metadata - .revocation_endpoint_auth_methods_supported - .iter() - .flatten(), )?; if let Some(url) = &metadata.introspection_endpoint { validate_url("introspection_endpoint", url, ExtraUrlRestrictions::None)?; } - // The list can also contain token types so remove them as we don't need to - // check them. - let introspection_methods = metadata - .introspection_endpoint_auth_methods_supported - .as_ref() - .map(|v| { - v.iter() - .filter_map(AuthenticationMethodOrAccessTokenType::authentication_method) - .collect::>() - }); validate_signing_alg_values_supported( "introspection_endpoint", metadata .introspection_endpoint_auth_signing_alg_values_supported .iter() .flatten(), - introspection_methods.into_iter().flatten(), )?; if let Some(url) = &metadata.userinfo_endpoint { @@ -1064,8 +1048,13 @@ pub enum ProviderMetadataVerificationError { UrlWithFragment(&'static str, Url), /// The issuer URL doesn't match the one that was discovered. - #[error("issuer URLs don't match")] - IssuerUrlsDontMatch, + #[error("issuer URLs don't match: expected {expected:?}, got {actual:?}")] + IssuerUrlsDontMatch { + /// The expected issuer URL. + expected: String, + /// The issuer URL that was discovered. + actual: String, + }, /// `openid` is missing from the supported scopes. #[error("missing openid scope")] @@ -1091,12 +1080,6 @@ pub enum ProviderMetadataVerificationError { #[error("missing `implicit` grant type")] GrantTypesMissingImplicit, - /// The given endpoint is missing auth signing algorithm values, but they - /// are required because it supports at least one of the `client_secret_jwt` - /// or `private_key_jwt` authentication methods. - #[error("{0} missing auth signing algorithm values")] - MissingAuthSigningAlgValues(&'static str), - /// `none` is in the given endpoint's signing algorithm values, but is not /// allowed. #[error("{0} signing algorithm values contain `none`")] @@ -1168,32 +1151,14 @@ fn validate_url( fn validate_signing_alg_values_supported<'a>( endpoint: &'static str, values: impl Iterator, - mut methods: impl Iterator, ) -> Result<(), ProviderMetadataVerificationError> { - let mut no_values = true; - for value in values { if *value == JsonWebSignatureAlg::None { return Err(ProviderMetadataVerificationError::SigningAlgValuesWithNone( endpoint, )); } - - no_values = false; } - - if no_values - && methods.any(|method| { - matches!( - method, - OAuthClientAuthenticationMethod::ClientSecretJwt - | OAuthClientAuthenticationMethod::PrivateKeyJwt - ) - }) - { - return Err(ProviderMetadataVerificationError::MissingAuthSigningAlgValues(endpoint)); - } - Ok(()) } @@ -1314,7 +1279,7 @@ mod tests { metadata.issuer = Some("https://example.com/".to_owned()); assert_matches!( metadata.clone().validate(&issuer), - Err(ProviderMetadataVerificationError::IssuerUrlsDontMatch) + Err(ProviderMetadataVerificationError::IssuerUrlsDontMatch { .. }) ); // Err - Not https @@ -1535,34 +1500,30 @@ mod tests { Some(vec![JsonWebSignatureAlg::Rs256, JsonWebSignatureAlg::EdDsa]); metadata.clone().validate(&issuer).unwrap(); - // Err - `client_secret_jwt` without signing alg values. + // Ok - `client_secret_jwt` with signing alg values. metadata.token_endpoint_auth_methods_supported = Some(vec![OAuthClientAuthenticationMethod::ClientSecretJwt]); - metadata.token_endpoint_auth_signing_alg_values_supported = None; - let endpoint = assert_matches!( - metadata.clone().validate(&issuer), - Err(ProviderMetadataVerificationError::MissingAuthSigningAlgValues(endpoint)) => endpoint - ); - assert_eq!(endpoint, "token_endpoint"); - - // Ok - `client_secret_jwt` with signing alg values. metadata.token_endpoint_auth_signing_alg_values_supported = Some(vec![JsonWebSignatureAlg::Rs256]); metadata.clone().validate(&issuer).unwrap(); - // Err - `private_key_jwt` without signing alg values. + // Ok - `private_key_jwt` with signing alg values. + metadata.token_endpoint_auth_methods_supported = + Some(vec![OAuthClientAuthenticationMethod::PrivateKeyJwt]); + metadata.token_endpoint_auth_signing_alg_values_supported = + Some(vec![JsonWebSignatureAlg::Rs256]); + metadata.clone().validate(&issuer).unwrap(); + + // Ok - `client_secret_jwt` without signing alg values. + metadata.token_endpoint_auth_methods_supported = + Some(vec![OAuthClientAuthenticationMethod::ClientSecretJwt]); + metadata.token_endpoint_auth_signing_alg_values_supported = None; + metadata.clone().validate(&issuer).unwrap(); + + // Ok - `private_key_jwt` without signing alg values. metadata.token_endpoint_auth_methods_supported = Some(vec![OAuthClientAuthenticationMethod::PrivateKeyJwt]); metadata.token_endpoint_auth_signing_alg_values_supported = None; - let endpoint = assert_matches!( - metadata.clone().validate(&issuer), - Err(ProviderMetadataVerificationError::MissingAuthSigningAlgValues(endpoint)) => endpoint - ); - assert_eq!(endpoint, "token_endpoint"); - - // Ok - `private_key_jwt` with signing alg values. - metadata.token_endpoint_auth_signing_alg_values_supported = - Some(vec![JsonWebSignatureAlg::Rs256]); metadata.clone().validate(&issuer).unwrap(); // Ok - Other auth methods without signing alg values. diff --git a/crates/oauth2-types/src/pkce.rs b/crates/oauth2-types/src/pkce.rs index 0682cb149..d806ae7f6 100644 --- a/crates/oauth2-types/src/pkce.rs +++ b/crates/oauth2-types/src/pkce.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Types for the [Proof Key for Code Exchange]. //! diff --git a/crates/oauth2-types/src/registration/client_metadata_serde.rs b/crates/oauth2-types/src/registration/client_metadata_serde.rs index 8ccefe36f..8edfcc6ca 100644 --- a/crates/oauth2-types/src/registration/client_metadata_serde.rs +++ b/crates/oauth2-types/src/registration/client_metadata_serde.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::borrow::Cow; diff --git a/crates/oauth2-types/src/registration/mod.rs b/crates/oauth2-types/src/registration/mod.rs index 27d99c2a8..e6b6aa862 100644 --- a/crates/oauth2-types/src/registration/mod.rs +++ b/crates/oauth2-types/src/registration/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Types for [Dynamic Client Registration]. //! @@ -440,7 +440,6 @@ impl ClientMetadata { /// Will return `Err` if validation fails. /// /// [OpenID Connect Dynamic Client Registration Spec 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata - #[allow(clippy::too_many_lines)] pub fn validate(self) -> Result { let grant_types = self.grant_types(); let has_implicit = grant_types.contains(&GrantType::Implicit); @@ -912,7 +911,8 @@ pub struct ClientRegistrationResponse { #[serde_as(as = "Option>")] pub client_id_issued_at: Option>, - /// Time at which the client_secret will expire or 0 if it will not expire. + /// Time at which the `client_secret` will expire or 0 if it will not + /// expire. /// /// Required if `client_secret` is issued. #[serde(default)] @@ -994,7 +994,6 @@ mod tests { } #[test] - #[allow(clippy::too_many_lines)] fn validate_response_types() { let mut metadata = valid_client_metadata(); diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index 36ee36da6..4c9f1117d 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests and response types to interact with the [OAuth 2.0] specification. //! @@ -86,11 +86,13 @@ impl core::str::FromStr for ResponseMode { Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr, )] #[non_exhaustive] +#[derive(Default)] pub enum Display { /// The Authorization Server should display the authentication and consent /// UI consistent with a full User Agent page view. /// /// This is the default display mode. + #[default] Page, /// The Authorization Server should display the authentication and consent @@ -135,12 +137,6 @@ impl core::str::FromStr for Display { } } -impl Default for Display { - fn default() -> Self { - Self::Page - } -} - /// Value that specifies whether the Authorization Server prompts the End-User /// for reauthentication and consent. /// @@ -807,6 +803,7 @@ pub struct IntrospectionResponse { pub jti: Option, /// MAS extension: explicit device ID + /// Only used for compatibility access and refresh tokens. pub device_id: Option, } diff --git a/crates/oauth2-types/src/response_type.rs b/crates/oauth2-types/src/response_type.rs index 1b4c4da03..1f3322e94 100644 --- a/crates/oauth2-types/src/response_type.rs +++ b/crates/oauth2-types/src/response_type.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! [Response types] in the OpenID Connect specification. //! @@ -272,7 +272,6 @@ mod tests { } #[test] - #[allow(clippy::too_many_lines)] fn deserialize_response_type() { serde_json::from_str::("\"\"").unwrap_err(); diff --git a/crates/oauth2-types/src/scope.rs b/crates/oauth2-types/src/scope.rs index a13719e54..f9832b5c0 100644 --- a/crates/oauth2-types/src/scope.rs +++ b/crates/oauth2-types/src/scope.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Types to define an [access token's scope]. //! @@ -10,7 +10,13 @@ #![allow(clippy::module_name_repetitions)] -use std::{borrow::Cow, collections::BTreeSet, iter::FromIterator, ops::Deref, str::FromStr}; +use std::{ + borrow::Cow, + collections::BTreeSet, + iter::FromIterator, + ops::{Deref, DerefMut}, + str::FromStr, +}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -121,6 +127,12 @@ impl Deref for Scope { } } +impl DerefMut for Scope { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + impl FromStr for Scope { type Err = InvalidScope; @@ -248,6 +260,7 @@ mod tests { ); assert!(Scope::from_str("http://example.com").is_ok()); - assert!(Scope::from_str("urn:matrix:org.matrix.msc2967.client:*").is_ok()); + assert!(Scope::from_str("urn:matrix:client:api:*").is_ok()); + assert!(Scope::from_str("urn:matrix:org.matrix.msc2967.client:api:*").is_ok()); } } diff --git a/crates/oauth2-types/src/test_utils.rs b/crates/oauth2-types/src/test_utils.rs index a1bdb30d5..69e58f9ea 100644 --- a/crates/oauth2-types/src/test_utils.rs +++ b/crates/oauth2-types/src/test_utils.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::fmt::Debug; diff --git a/crates/oauth2-types/src/webfinger.rs b/crates/oauth2-types/src/webfinger.rs index 17d7557c4..34e132324 100644 --- a/crates/oauth2-types/src/webfinger.rs +++ b/crates/oauth2-types/src/webfinger.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Types for provider discovery using [Webfinger]. //! diff --git a/crates/oidc-client/Cargo.toml b/crates/oidc-client/Cargo.toml index 5b35ec913..3fffd584c 100644 --- a/crates/oidc-client/Cargo.toml +++ b/crates/oidc-client/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-oidc-client" description = "OpenID Connect client library used by the Matrix Authentication Service" diff --git a/crates/oidc-client/src/error.rs b/crates/oidc-client/src/error.rs index 1642054e7..0f424464f 100644 --- a/crates/oidc-client/src/error.rs +++ b/crates/oidc-client/src/error.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! The error types used in this crate. diff --git a/crates/oidc-client/src/lib.rs b/crates/oidc-client/src/lib.rs index 24da41909..2b35896be 100644 --- a/crates/oidc-client/src/lib.rs +++ b/crates/oidc-client/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! An [OpenID Connect] client library for the [Matrix] specification. //! diff --git a/crates/oidc-client/src/requests/authorization_code.rs b/crates/oidc-client/src/requests/authorization_code.rs index 9271fe33c..4965e13d0 100644 --- a/crates/oidc-client/src/requests/authorization_code.rs +++ b/crates/oidc-client/src/requests/authorization_code.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests for the [Authorization Code flow]. //! diff --git a/crates/oidc-client/src/requests/client_credentials.rs b/crates/oidc-client/src/requests/client_credentials.rs index aebf412b8..539d1a715 100644 --- a/crates/oidc-client/src/requests/client_credentials.rs +++ b/crates/oidc-client/src/requests/client_credentials.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests for the [Client Credentials flow]. //! diff --git a/crates/oidc-client/src/requests/discovery.rs b/crates/oidc-client/src/requests/discovery.rs index 1d724b553..e3807c412 100644 --- a/crates/oidc-client/src/requests/discovery.rs +++ b/crates/oidc-client/src/requests/discovery.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests for OpenID Connect Provider [Discovery]. //! diff --git a/crates/oidc-client/src/requests/jose.rs b/crates/oidc-client/src/requests/jose.rs index a4c858359..12915caab 100644 --- a/crates/oidc-client/src/requests/jose.rs +++ b/crates/oidc-client/src/requests/jose.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests and method related to JSON Object Signing and Encryption. diff --git a/crates/oidc-client/src/requests/mod.rs b/crates/oidc-client/src/requests/mod.rs index 757b3d527..24cc9e27d 100644 --- a/crates/oidc-client/src/requests/mod.rs +++ b/crates/oidc-client/src/requests/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Methods to interact with OpenID Connect and OAuth2.0 endpoints. diff --git a/crates/oidc-client/src/requests/refresh_token.rs b/crates/oidc-client/src/requests/refresh_token.rs index 8b9e7719a..9af088b0c 100644 --- a/crates/oidc-client/src/requests/refresh_token.rs +++ b/crates/oidc-client/src/requests/refresh_token.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests for using [Refresh Tokens]. //! diff --git a/crates/oidc-client/src/requests/token.rs b/crates/oidc-client/src/requests/token.rs index 38e988495..774ab93dd 100644 --- a/crates/oidc-client/src/requests/token.rs +++ b/crates/oidc-client/src/requests/token.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests for the Token endpoint. diff --git a/crates/oidc-client/src/requests/userinfo.rs b/crates/oidc-client/src/requests/userinfo.rs index fbcd27039..e10526fcf 100644 --- a/crates/oidc-client/src/requests/userinfo.rs +++ b/crates/oidc-client/src/requests/userinfo.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Requests for obtaining [Claims] about an end-user. //! diff --git a/crates/oidc-client/src/types/client_credentials.rs b/crates/oidc-client/src/types/client_credentials.rs index ca8d2a8f6..a097ef469 100644 --- a/crates/oidc-client/src/types/client_credentials.rs +++ b/crates/oidc-client/src/types/client_credentials.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Types and methods for client credentials. @@ -130,7 +130,6 @@ impl ClientCredentials { /// Apply these [`ClientCredentials`] to the given request with the given /// form. - #[allow(clippy::too_many_lines)] pub(crate) fn authenticated_form( &self, request: reqwest::RequestBuilder, @@ -141,7 +140,7 @@ impl ClientCredentials { let request = match self { ClientCredentials::None { client_id } => request.form(&RequestWithClientCredentials { body: form, - client_id, + client_id: Some(client_id), client_secret: None, client_assertion: None, client_assertion_type: None, @@ -159,7 +158,7 @@ impl ClientCredentials { .basic_auth(username, Some(password)) .form(&RequestWithClientCredentials { body: form, - client_id, + client_id: None, client_secret: None, client_assertion: None, client_assertion_type: None, @@ -171,7 +170,7 @@ impl ClientCredentials { client_secret, } => request.form(&RequestWithClientCredentials { body: form, - client_id, + client_id: Some(client_id), client_secret: Some(client_secret), client_assertion: None, client_assertion_type: None, @@ -195,7 +194,7 @@ impl ClientCredentials { request.form(&RequestWithClientCredentials { body: form, - client_id, + client_id: None, client_secret: None, client_assertion: Some(jwt.as_str()), client_assertion_type: Some(JwtBearerClientAssertionType), @@ -228,7 +227,7 @@ impl ClientCredentials { request.form(&RequestWithClientCredentials { body: form, - client_id, + client_id: None, client_secret: None, client_assertion: Some(client_assertion.as_str()), client_assertion_type: Some(JwtBearerClientAssertionType), @@ -260,7 +259,7 @@ impl ClientCredentials { request.form(&RequestWithClientCredentials { body: form, - client_id, + client_id: Some(client_id), client_secret: Some(client_secret.as_str()), client_assertion: None, client_assertion_type: None, @@ -359,7 +358,8 @@ struct RequestWithClientCredentials<'a, T> { #[serde(flatten)] body: T, - client_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + client_id: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] client_secret: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/oidc-client/src/types/mod.rs b/crates/oidc-client/src/types/mod.rs index 9f4e79f8c..79fe33a1c 100644 --- a/crates/oidc-client/src/types/mod.rs +++ b/crates/oidc-client/src/types/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! OAuth 2.0 and OpenID Connect types. diff --git a/crates/oidc-client/tests/it/main.rs b/crates/oidc-client/tests/it/main.rs index 270ab8a20..cc8641085 100644 --- a/crates/oidc-client/tests/it/main.rs +++ b/crates/oidc-client/tests/it/main.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; @@ -74,7 +74,7 @@ fn keystore(alg: &JsonWebSignatureAlg) -> Keystore { } /// Generate an ID token. -fn id_token(issuer: &str) -> (IdToken, PublicJsonWebKeySet) { +fn id_token(issuer: &str) -> (IdToken<'_>, PublicJsonWebKeySet) { let signing_alg = ID_TOKEN_SIGNING_ALG; let keystore = keystore(&signing_alg); diff --git a/crates/oidc-client/tests/it/requests/authorization_code.rs b/crates/oidc-client/tests/it/requests/authorization_code.rs index d0eb2a8c1..cc3f5b210 100644 --- a/crates/oidc-client/tests/it/requests/authorization_code.rs +++ b/crates/oidc-client/tests/it/requests/authorization_code.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, num::NonZeroU32}; diff --git a/crates/oidc-client/tests/it/requests/client_credentials.rs b/crates/oidc-client/tests/it/requests/client_credentials.rs index a6da9e0ff..00b3c774f 100644 --- a/crates/oidc-client/tests/it/requests/client_credentials.rs +++ b/crates/oidc-client/tests/it/requests/client_credentials.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; diff --git a/crates/oidc-client/tests/it/requests/discovery.rs b/crates/oidc-client/tests/it/requests/discovery.rs index 4b2c9d18c..cacdf7d2e 100644 --- a/crates/oidc-client/tests/it/requests/discovery.rs +++ b/crates/oidc-client/tests/it/requests/discovery.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use assert_matches::assert_matches; use mas_iana::oauth::{OAuthAuthorizationEndpointResponseType, PkceCodeChallengeMethod}; diff --git a/crates/oidc-client/tests/it/requests/jose.rs b/crates/oidc-client/tests/it/requests/jose.rs index d45358f28..6adedc570 100644 --- a/crates/oidc-client/tests/it/requests/jose.rs +++ b/crates/oidc-client/tests/it/requests/jose.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; @@ -34,7 +34,7 @@ fn id_token( issuer: &str, flag: Option, auth_time: Option>, -) -> (IdToken, PublicJsonWebKeySet) { +) -> (IdToken<'_>, PublicJsonWebKeySet) { let signing_alg = ID_TOKEN_SIGNING_ALG; let keystore = keystore(&signing_alg); diff --git a/crates/oidc-client/tests/it/requests/mod.rs b/crates/oidc-client/tests/it/requests/mod.rs index ee2bde285..fce40a2e0 100644 --- a/crates/oidc-client/tests/it/requests/mod.rs +++ b/crates/oidc-client/tests/it/requests/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod authorization_code; mod client_credentials; diff --git a/crates/oidc-client/tests/it/requests/refresh_token.rs b/crates/oidc-client/tests/it/requests/refresh_token.rs index ebda10a10..9b6e7c390 100644 --- a/crates/oidc-client/tests/it/requests/refresh_token.rs +++ b/crates/oidc-client/tests/it/requests/refresh_token.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; diff --git a/crates/oidc-client/tests/it/requests/userinfo.rs b/crates/oidc-client/tests/it/requests/userinfo.rs index 2b3f9cfbd..f7d979ab0 100644 --- a/crates/oidc-client/tests/it/requests/userinfo.rs +++ b/crates/oidc-client/tests/it/requests/userinfo.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_oidc_client::requests::userinfo::fetch_userinfo; use serde_json::json; diff --git a/crates/oidc-client/tests/it/types/client_credentials.rs b/crates/oidc-client/tests/it/types/client_credentials.rs index b1939f40c..c53a98e01 100644 --- a/crates/oidc-client/tests/it/types/client_credentials.rs +++ b/crates/oidc-client/tests/it/types/client_credentials.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashMap; @@ -188,12 +188,8 @@ async fn pass_client_secret_jwt() { .and(move |req: &Request| { let query_pairs = form_urlencoded::parse(&req.body).collect::>(); - if query_pairs - .get("client_id") - .filter(|s| *s == CLIENT_ID) - .is_none() - { - println!("Wrong or missing client ID"); + if query_pairs.contains_key("client_id") { + println!("`client_secret_jwt` client authentication should not use `client_id`"); return false; } if query_pairs @@ -271,12 +267,8 @@ async fn pass_private_key_jwt() { .and(move |req: &Request| { let query_pairs = form_urlencoded::parse(&req.body).collect::>(); - if query_pairs - .get("client_id") - .filter(|s| *s == CLIENT_ID) - .is_none() - { - println!("Wrong or missing client ID"); + if query_pairs.contains_key("client_id") { + println!("`private_key_jwt` client authentication should not use `client_id`"); return false; } if query_pairs diff --git a/crates/oidc-client/tests/it/types/mod.rs b/crates/oidc-client/tests/it/types/mod.rs index a45ac15bd..a09d66a7b 100644 --- a/crates/oidc-client/tests/it/types/mod.rs +++ b/crates/oidc-client/tests/it/types/mod.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 Kévin Commaille. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod client_credentials; diff --git a/crates/policy/Cargo.toml b/crates/policy/Cargo.toml index ca927bcc6..7496f2726 100644 --- a/crates/policy/Cargo.toml +++ b/crates/policy/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-policy" version.workspace = true diff --git a/crates/policy/src/bin/schema.rs b/crates/policy/src/bin/schema.rs index a07919276..0e4ca9d87 100644 --- a/crates/policy/src/bin/schema.rs +++ b/crates/policy/src/bin/schema.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![expect( clippy::disallowed_types, @@ -23,7 +23,7 @@ use schemars::{ fn write_schema(out_dir: Option<&Path>, file: &str) { let mut writer: Box = if let Some(out_dir) = out_dir { let path = out_dir.join(file); - eprintln!("Writing to {path:?}"); + eprintln!("Writing to {}", path.display()); let file = std::fs::File::create(path).expect("Failed to create file"); Box::new(std::io::BufWriter::new(file)) } else { diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 5a714e9a2..3a3a23c3f 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub mod model; @@ -197,6 +197,11 @@ pub struct PolicyFactory { } impl PolicyFactory { + /// Load the policy from the given data source. + /// + /// # Errors + /// + /// Returns an error if the policy can't be loaded or instantiated. #[tracing::instrument(name = "policy.load", skip(source))] pub async fn load( mut source: impl AsyncRead + std::marker::Unpin, @@ -283,6 +288,12 @@ impl PolicyFactory { Ok(true) } + /// Create a new policy instance. + /// + /// # Errors + /// + /// Returns an error if the policy can't be instantiated with the current + /// dynamic data. #[tracing::instrument(name = "policy.instantiate", skip_all)] pub async fn instantiate(&self) -> Result { let data = self.dynamic_data.load(); @@ -336,6 +347,11 @@ pub enum EvaluationError { } impl Policy { + /// Evaluate the 'email' entrypoint. + /// + /// # Errors + /// + /// Returns an error if the policy engine fails to evaluate the entrypoint. #[tracing::instrument( name = "policy.evaluate_email", skip_all, @@ -355,6 +371,11 @@ impl Policy { Ok(res) } + /// Evaluate the 'register' entrypoint. + /// + /// # Errors + /// + /// Returns an error if the policy engine fails to evaluate the entrypoint. #[tracing::instrument( name = "policy.evaluate.register", skip_all, @@ -376,6 +397,11 @@ impl Policy { Ok(res) } + /// Evaluate the `client_registration` entrypoint. + /// + /// # Errors + /// + /// Returns an error if the policy engine fails to evaluate the entrypoint. #[tracing::instrument(skip(self))] pub async fn evaluate_client_registration( &mut self, @@ -393,6 +419,11 @@ impl Policy { Ok(res) } + /// Evaluate the `authorization_grant` entrypoint. + /// + /// # Errors + /// + /// Returns an error if the policy engine fails to evaluate the entrypoint. #[tracing::instrument( name = "policy.evaluate.authorization_grant", skip_all, diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index d57a81655..2f54ae8bb 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Input and output types for policy evaluation. //! diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index b31302fc0..07cd3a913 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-router" version.workspace = true diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 896f17a52..3440f8bc6 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use serde::{Deserialize, Serialize}; use ulid::Ulid; @@ -738,6 +738,29 @@ impl Route for UpstreamOAuth2Link { } } +/// `POST /upstream/backchannel-logout/{id}` +pub struct UpstreamOAuth2BackchannelLogout { + id: Ulid, +} + +impl UpstreamOAuth2BackchannelLogout { + #[must_use] + pub const fn new(id: Ulid) -> Self { + Self { id } + } +} + +impl Route for UpstreamOAuth2BackchannelLogout { + type Query = (); + fn route() -> &'static str { + "/upstream/backchannel-logout/{provider_id}" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/upstream/backchannel-logout/{}", self.id).into() + } +} + /// `GET|POST /link` #[derive(Default, Serialize, Deserialize, Debug, Clone)] pub struct DeviceCodeLink { diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index 5a06f1891..0308fdfcd 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. pub(crate) mod endpoints; pub(crate) mod traits; diff --git a/crates/router/src/traits.rs b/crates/router/src/traits.rs index 53bb40678..5e7f13bdb 100644 --- a/crates/router/src/traits.rs +++ b/crates/router/src/traits.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::borrow::Cow; diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index d3a2f6f64..f216fb343 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Utility to build URLs diff --git a/crates/spa/Cargo.toml b/crates/spa/Cargo.toml index 292338a67..5287abf15 100644 --- a/crates/spa/Cargo.toml +++ b/crates/spa/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-spa" version.workspace = true diff --git a/crates/spa/src/lib.rs b/crates/spa/src/lib.rs index 91fc183d0..af3be8def 100644 --- a/crates/spa/src/lib.rs +++ b/crates/spa/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(rustdoc::missing_crate_level_docs)] diff --git a/crates/spa/src/vite.rs b/crates/spa/src/vite.rs index 31d5d3c7e..b488bea6b 100644 --- a/crates/spa/src/vite.rs +++ b/crates/spa/src/vite.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::{BTreeSet, HashMap}; @@ -12,10 +12,13 @@ use thiserror::Error; #[derive(serde::Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ManifestEntry { - #[allow(dead_code)] + #[expect(dead_code)] name: Option, - #[allow(dead_code)] + #[expect(dead_code)] + names: Option>, + + #[expect(dead_code)] src: Option, file: Utf8PathBuf, @@ -24,15 +27,15 @@ pub struct ManifestEntry { assets: Option>, - #[allow(dead_code)] + #[expect(dead_code)] is_entry: Option, - #[allow(dead_code)] + #[expect(dead_code)] is_dynamic_entry: Option, imports: Option>, - #[allow(dead_code)] + #[expect(dead_code)] dynamic_imports: Option>, integrity: Option, diff --git a/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json b/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json new file mode 100644 index 000000000..55509569c --- /dev/null +++ b/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_sessions\n SET revoked_at = $2\n WHERE personal_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa" +} diff --git a/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json b/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json new file mode 100644 index 000000000..5bba6548d --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_access_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d" +} diff --git a/crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json b/crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json new file mode 100644 index 000000000..1eb87fd3f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters,\n forward_login_hint,\n ui_order,\n on_backchannel_logout,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24, $25)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n id_token_signed_response_alg = EXCLUDED.id_token_signed_response_alg,\n fetch_userinfo = EXCLUDED.fetch_userinfo,\n userinfo_signed_response_alg = EXCLUDED.userinfo_signed_response_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n response_mode = EXCLUDED.response_mode,\n additional_parameters = EXCLUDED.additional_parameters,\n forward_login_hint = EXCLUDED.forward_login_hint,\n ui_order = EXCLUDED.ui_order,\n on_backchannel_logout = EXCLUDED.on_backchannel_logout\n RETURNING created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Bool", + "Int4", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c" +} diff --git a/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json b/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json new file mode 100644 index 000000000..83400921a --- /dev/null +++ b/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_sessions\n ( personal_session_id\n , owner_user_id\n , owner_oauth2_client_id\n , actor_user_id\n , human_name\n , scope_list\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Text", + "TextArray", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2" +} diff --git a/crates/storage-pg/.sqlx/query-d2a4f5c01603463b78198529d295f7f121769ea5730d01c20c0ddbcdc79a5716.json b/crates/storage-pg/.sqlx/query-23d5fcd8bf611dc7279bef0d66ce05461c3c1f43f966fee3a80ae42540783f08.json similarity index 74% rename from crates/storage-pg/.sqlx/query-d2a4f5c01603463b78198529d295f7f121769ea5730d01c20c0ddbcdc79a5716.json rename to crates/storage-pg/.sqlx/query-23d5fcd8bf611dc7279bef0d66ce05461c3c1f43f966fee3a80ae42540783f08.json index 171a83623..28391d844 100644 --- a/crates/storage-pg/.sqlx/query-d2a4f5c01603463b78198529d295f7f121769ea5730d01c20c0ddbcdc79a5716.json +++ b/crates/storage-pg/.sqlx/query-23d5fcd8bf611dc7279bef0d66ce05461c3c1f43f966fee3a80ae42540783f08.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE LOWER(username) = LOWER($1)\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n , is_guest\n FROM users\n WHERE LOWER(username) = LOWER($1)\n ", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "can_request_admin", "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "is_guest", + "type_info": "Bool" } ], "parameters": { @@ -45,8 +50,9 @@ false, true, true, + false, false ] }, - "hash": "d2a4f5c01603463b78198529d295f7f121769ea5730d01c20c0ddbcdc79a5716" + "hash": "23d5fcd8bf611dc7279bef0d66ce05461c3c1f43f966fee3a80ae42540783f08" } diff --git a/crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json b/crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json new file mode 100644 index 000000000..21a67060b --- /dev/null +++ b/crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM personal_access_tokens\n WHERE personal_session_id IN (\n SELECT personal_session_id\n FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be" +} diff --git a/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json b/crates/storage-pg/.sqlx/query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json similarity index 78% rename from crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json rename to crates/storage-pg/.sqlx/query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json index 9944e855b..3f837630f 100644 --- a/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json +++ b/crates/storage-pg/.sqlx/query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n forward_login_hint,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)\n ", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n forward_login_hint,\n on_backchannel_logout,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23)\n ", "describe": { "columns": [], "parameters": { @@ -26,10 +26,11 @@ "Text", "Text", "Bool", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c" + "hash": "3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81" } diff --git a/crates/storage-pg/.sqlx/query-373f7eb215b0e515b000a37e55bd055954f697f257de026b74ec408938a52a1a.json b/crates/storage-pg/.sqlx/query-373f7eb215b0e515b000a37e55bd055954f697f257de026b74ec408938a52a1a.json deleted file mode 100644 index 9ebd78f6f..000000000 --- a/crates/storage-pg/.sqlx/query-373f7eb215b0e515b000a37e55bd055954f697f257de026b74ec408938a52a1a.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE oauth2_sessions SET finished_at = $3 WHERE user_id = $1 AND $2 = ANY(scope_list) AND finished_at IS NULL\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "373f7eb215b0e515b000a37e55bd055954f697f257de026b74ec408938a52a1a" -} diff --git a/crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json b/crates/storage-pg/.sqlx/query-4dad1838536c10ba723adc0fb6da0f24afb3d6a1925a80a1b6d35b9a8258a0ce.json similarity index 75% rename from crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json rename to crates/storage-pg/.sqlx/query-4dad1838536c10ba723adc0fb6da0f24afb3d6a1925a80a1b6d35b9a8258a0ce.json index 6603fa37d..6c8cdbe88 100644 --- a/crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json +++ b/crates/storage-pg/.sqlx/query-4dad1838536c10ba723adc0fb6da0f24afb3d6a1925a80a1b6d35b9a8258a0ce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n , is_guest\n FROM users\n WHERE user_id = $1\n ", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "can_request_admin", "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "is_guest", + "type_info": "Bool" } ], "parameters": { @@ -45,8 +50,9 @@ false, true, true, + false, false ] }, - "hash": "cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d" + "hash": "4dad1838536c10ba723adc0fb6da0f24afb3d6a1925a80a1b6d35b9a8258a0ce" } diff --git a/crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json b/crates/storage-pg/.sqlx/query-572ead41d62cfbe40e6f0c8edf6928e8eebd99036255b62d688ac02b5bd74b40.json similarity index 84% rename from crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json rename to crates/storage-pg/.sqlx/query-572ead41d62cfbe40e6f0c8edf6928e8eebd99036255b62d688ac02b5bd74b40.json index 1ddb0acc8..155277181 100644 --- a/crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json +++ b/crates/storage-pg/.sqlx/query-572ead41d62cfbe40e6f0c8edf6928e8eebd99036255b62d688ac02b5bd74b40.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.deactivated_at AS \"user_deactivated_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", + "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.deactivated_at AS \"user_deactivated_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n , u.is_guest AS \"user_is_guest\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", "describe": { "columns": [ { @@ -62,6 +62,11 @@ "ordinal": 11, "name": "user_can_request_admin", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "user_is_guest", + "type_info": "Bool" } ], "parameters": { @@ -81,8 +86,9 @@ false, true, true, + false, false ] }, - "hash": "f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8" + "hash": "572ead41d62cfbe40e6f0c8edf6928e8eebd99036255b62d688ac02b5bd74b40" } diff --git a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json b/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json deleted file mode 100644 index a7b63ca21..000000000 --- a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters,\n forward_login_hint,\n ui_order,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n id_token_signed_response_alg = EXCLUDED.id_token_signed_response_alg,\n fetch_userinfo = EXCLUDED.fetch_userinfo,\n userinfo_signed_response_alg = EXCLUDED.userinfo_signed_response_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n response_mode = EXCLUDED.response_mode,\n additional_parameters = EXCLUDED.additional_parameters,\n forward_login_hint = EXCLUDED.forward_login_hint,\n ui_order = EXCLUDED.ui_order\n RETURNING created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Bool", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Bool", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f" -} diff --git a/crates/storage-pg/.sqlx/query-5da7a197e0008f100ad4daa78f4aa6515f0fc9eb54075e8d6d15520d25b75172.json b/crates/storage-pg/.sqlx/query-5da7a197e0008f100ad4daa78f4aa6515f0fc9eb54075e8d6d15520d25b75172.json new file mode 100644 index 000000000..56298e4da --- /dev/null +++ b/crates/storage-pg/.sqlx/query-5da7a197e0008f100ad4daa78f4aa6515f0fc9eb54075e8d6d15520d25b75172.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth2_sessions\n SET finished_at = $4\n WHERE user_id = $1\n AND ($2 = ANY(scope_list) OR $3 = ANY(scope_list))\n AND finished_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "5da7a197e0008f100ad4daa78f4aa6515f0fc9eb54075e8d6d15520d25b75172" +} diff --git a/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json b/crates/storage-pg/.sqlx/query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json similarity index 85% rename from crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json rename to crates/storage-pg/.sqlx/query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json index cc7f8d1af..3d81b9141 100644 --- a/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json +++ b/crates/storage-pg/.sqlx/query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n WHERE email = $1\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n WHERE LOWER(email) = LOWER($1)\n ", "describe": { "columns": [ { @@ -36,5 +36,5 @@ false ] }, - "hash": "f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec" + "hash": "5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7" } diff --git a/crates/storage-pg/.sqlx/query-5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f.json b/crates/storage-pg/.sqlx/query-5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f.json deleted file mode 100644 index c33da04d8..000000000 --- a/crates/storage-pg/.sqlx/query-5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE upstream_oauth_authorization_sessions\n SET upstream_oauth_link_id = $1,\n completed_at = $2,\n id_token = $3,\n extra_callback_parameters = $4,\n userinfo = $5\n WHERE upstream_oauth_authorization_session_id = $6\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Timestamptz", - "Text", - "Jsonb", - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f" -} diff --git a/crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json b/crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json new file mode 100644 index 000000000..6b2e85bf1 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_sessions\n SET last_active_at = GREATEST(t.last_active_at, personal_sessions.last_active_at)\n , last_active_ip = COALESCE(t.last_active_ip, personal_sessions.last_active_ip)\n FROM (\n SELECT *\n FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[])\n AS t(personal_session_id, last_active_at, last_active_ip)\n ) AS t\n WHERE personal_sessions.personal_session_id = t.personal_session_id\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TimestamptzArray", + "InetArray" + ] + }, + "nullable": [] + }, + "hash": "64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e" +} diff --git a/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json b/crates/storage-pg/.sqlx/query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json similarity index 91% rename from crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json rename to crates/storage-pg/.sqlx/query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json index 7c1a26a86..6bd2768cc 100644 --- a/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json +++ b/crates/storage-pg/.sqlx/query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint,\n on_backchannel_logout\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -122,6 +122,11 @@ "ordinal": 23, "name": "forward_login_hint", "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "on_backchannel_logout", + "type_info": "Text" } ], "parameters": { @@ -153,8 +158,9 @@ false, true, true, + false, false ] }, - "hash": "a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89" + "hash": "6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792" } diff --git a/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json b/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json new file mode 100644 index 000000000..66aab4ee6 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE access_token_sha256 = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20" +} diff --git a/crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json b/crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json new file mode 100644 index 000000000..75f013b53 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET deactivated_at = NULL\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1" +} diff --git a/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json b/crates/storage-pg/.sqlx/query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json similarity index 89% rename from crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json rename to crates/storage-pg/.sqlx/query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json index d544590c4..eb1a801c4 100644 --- a/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json +++ b/crates/storage-pg/.sqlx/query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint,\n on_backchannel_logout\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", "describe": { "columns": [ { @@ -122,6 +122,11 @@ "ordinal": 23, "name": "forward_login_hint", "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "on_backchannel_logout", + "type_info": "Text" } ], "parameters": { @@ -151,8 +156,9 @@ false, true, true, + false, false ] }, - "hash": "e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917" + "hash": "99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8" } diff --git a/crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json b/crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json new file mode 100644 index 000000000..0a838d1a5 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_session_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292" +} diff --git a/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json b/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json new file mode 100644 index 000000000..3542f8481 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_access_tokens\n (personal_access_token_id, personal_session_id, access_token_sha256, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056" +} diff --git a/crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json b/crates/storage-pg/.sqlx/query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json similarity index 84% rename from crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json rename to crates/storage-pg/.sqlx/query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json index 3e278f223..8ae291bb6 100644 --- a/crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json +++ b/crates/storage-pg/.sqlx/query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_id = $1 AND email = $2\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_id = $1 AND LOWER(email) = LOWER($2)\n ", "describe": { "columns": [ { @@ -37,5 +37,5 @@ false ] }, - "hash": "f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5" + "hash": "ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667" } diff --git a/crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json b/crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json new file mode 100644 index 000000000..99df8e139 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_session_id = $1\n AND revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3" +} diff --git a/crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json b/crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json new file mode 100644 index 000000000..39447cd10 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e" +} diff --git a/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json b/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json new file mode 100644 index 000000000..2112e7603 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_access_token_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e" +} diff --git a/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json b/crates/storage-pg/.sqlx/query-e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9.json similarity index 77% rename from crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json rename to crates/storage-pg/.sqlx/query-e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9.json index 0e28ac022..c3c2e2507 100644 --- a/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json +++ b/crates/storage-pg/.sqlx/query-e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n extra_callback_parameters,\n userinfo,\n created_at,\n completed_at,\n consumed_at,\n unlinked_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n id_token_claims,\n extra_callback_parameters,\n userinfo,\n created_at,\n completed_at,\n consumed_at,\n unlinked_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ", "describe": { "columns": [ { @@ -40,31 +40,36 @@ }, { "ordinal": 7, - "name": "extra_callback_parameters", + "name": "id_token_claims", "type_info": "Jsonb" }, { "ordinal": 8, - "name": "userinfo", + "name": "extra_callback_parameters", "type_info": "Jsonb" }, { "ordinal": 9, + "name": "userinfo", + "type_info": "Jsonb" + }, + { + "ordinal": 10, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "completed_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "consumed_at", "type_info": "Timestamptz" }, { - "ordinal": 12, + "ordinal": 13, "name": "unlinked_at", "type_info": "Timestamptz" } @@ -84,11 +89,12 @@ true, true, true, + true, false, true, true, true ] }, - "hash": "37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42" + "hash": "e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9" } diff --git a/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json b/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json index f5503fa0e..ef1ac0372 100644 --- a/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json +++ b/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json @@ -23,7 +23,7 @@ "Left": [] }, "nullable": [ - false, + true, true, null ] diff --git a/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json b/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json new file mode 100644 index 000000000..b46904ccb --- /dev/null +++ b/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_session_id\n , owner_user_id\n , owner_oauth2_client_id\n , actor_user_id\n , scope_list\n , created_at\n , revoked_at\n , human_name\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n FROM personal_sessions\n\n WHERE personal_session_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "owner_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "owner_oauth2_client_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "actor_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "scope_list", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "human_name", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_active_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_active_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2" +} diff --git a/crates/storage-pg/.sqlx/query-fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50.json b/crates/storage-pg/.sqlx/query-fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50.json new file mode 100644 index 000000000..072e6f57b --- /dev/null +++ b/crates/storage-pg/.sqlx/query-fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE upstream_oauth_authorization_sessions\n SET upstream_oauth_link_id = $1\n , completed_at = $2\n , id_token = $3\n , id_token_claims = $4\n , extra_callback_parameters = $5\n , userinfo = $6\n WHERE upstream_oauth_authorization_session_id = $7\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz", + "Text", + "Jsonb", + "Jsonb", + "Jsonb", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50" +} diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml index 0bf34ec0b..8710ead70 100644 --- a/crates/storage-pg/Cargo.toml +++ b/crates/storage-pg/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-storage-pg" version.workspace = true @@ -22,6 +27,7 @@ rand.workspace = true sea-query-binder.workspace = true sea-query.workspace = true serde_json.workspace = true +sha2.workspace = true sqlx.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/storage-pg/build.rs b/crates/storage-pg/build.rs index d13fb1c76..007c4622f 100644 --- a/crates/storage-pg/build.rs +++ b/crates/storage-pg/build.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. fn main() { // trigger recompilation when a new migration is added diff --git a/crates/storage-pg/migrations/20250602212102_upstream_oauth2_id_token_claims.sql b/crates/storage-pg/migrations/20250602212102_upstream_oauth2_id_token_claims.sql new file mode 100644 index 000000000..6cb78a4c2 --- /dev/null +++ b/crates/storage-pg/migrations/20250602212102_upstream_oauth2_id_token_claims.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This is the decoded claims from the ID token stored as JSONB +ALTER TABLE upstream_oauth_authorization_sessions + ADD COLUMN id_token_claims JSONB; diff --git a/crates/storage-pg/migrations/20250602212103_upstream_oauth2_id_token_claims_sub_sid_index.sql b/crates/storage-pg/migrations/20250602212103_upstream_oauth2_id_token_claims_sub_sid_index.sql new file mode 100644 index 000000000..327022168 --- /dev/null +++ b/crates/storage-pg/migrations/20250602212103_upstream_oauth2_id_token_claims_sub_sid_index.sql @@ -0,0 +1,15 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- We'll be requesting authorization sessions by provider, sub and sid, so we'll +-- need to index those columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS + upstream_oauth_authorization_sessions_sub_sid_idx + ON upstream_oauth_authorization_sessions ( + upstream_oauth_provider_id, + (id_token_claims->>'sub'), + (id_token_claims->>'sid') + ); diff --git a/crates/storage-pg/migrations/20250602212104_upstream_oauth2_id_token_claims_sid_sub_index.sql b/crates/storage-pg/migrations/20250602212104_upstream_oauth2_id_token_claims_sid_sub_index.sql new file mode 100644 index 000000000..097c3da32 --- /dev/null +++ b/crates/storage-pg/migrations/20250602212104_upstream_oauth2_id_token_claims_sid_sub_index.sql @@ -0,0 +1,15 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- We'll be requesting authorization sessions by provider, sub and sid, so we'll +-- need to index those columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS + upstream_oauth_authorization_sessions_sid_sub_idx + ON upstream_oauth_authorization_sessions ( + upstream_oauth_provider_id, + (id_token_claims->>'sid'), + (id_token_claims->>'sub') + ); diff --git a/crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql b/crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql new file mode 100644 index 000000000..f6031ca62 --- /dev/null +++ b/crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql @@ -0,0 +1,10 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This defines the behavior when receiving a backchannel logout notification +ALTER TABLE "upstream_oauth_providers" + ADD COLUMN "on_backchannel_logout" TEXT + NOT NULL + DEFAULT 'do_nothing'; diff --git a/crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql b/crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql new file mode 100644 index 000000000..06b3dde6a --- /dev/null +++ b/crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql @@ -0,0 +1,11 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- When we're looking up an email address, we want to be able to do a case-insensitive +-- lookup, so we index the email address lowercase and request it like that +CREATE INDEX CONCURRENTLY + user_emails_lower_email_idx + ON user_emails (LOWER(email)); diff --git a/crates/storage-pg/migrations/20250709142230_id_token_claims_trigger.sql b/crates/storage-pg/migrations/20250709142230_id_token_claims_trigger.sql new file mode 100644 index 000000000..32d304721 --- /dev/null +++ b/crates/storage-pg/migrations/20250709142230_id_token_claims_trigger.sql @@ -0,0 +1,51 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- We may be running an older version of the app that doesn't fill in the +-- id_token_claims column when the id_token column is populated. So we add a +-- trigger to fill in the id_token_claims column if it's NULL. +-- +-- We will be able to remove this trigger in a future version of the app. +-- +-- We backfill in a second migration after this one to make sure we don't miss +-- any rows, and don't lock the table for too long. +CREATE OR REPLACE FUNCTION fill_id_token_claims() +RETURNS TRIGGER AS $$ +BEGIN + -- Only process if id_token_claims is NULL but id_token is not NULL + IF NEW.id_token_claims IS NULL AND NEW.id_token IS NOT NULL AND NEW.id_token != '' THEN + BEGIN + -- Decode JWT payload inline + NEW.id_token_claims := ( + CASE + WHEN split_part(NEW.id_token, '.', 2) = '' THEN NULL + ELSE + (convert_from( + decode( + replace(replace(split_part(NEW.id_token, '.', 2), '-', '+'), '_', '/') || + repeat('=', (4 - length(split_part(NEW.id_token, '.', 2)) % 4) % 4), + 'base64' + ), + 'UTF8' + ))::JSONB + END + ); + EXCEPTION + WHEN OTHERS THEN + -- If JWT decoding fails, leave id_token_claims as NULL + NEW.id_token_claims := NULL; + END; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create the trigger +CREATE TRIGGER trg_fill_id_token_claims + BEFORE INSERT OR UPDATE ON upstream_oauth_authorization_sessions + FOR EACH ROW + WHEN (NEW.id_token_claims IS NULL AND NEW.id_token IS NOT NULL AND NEW.id_token <> '') + EXECUTE FUNCTION fill_id_token_claims(); diff --git a/crates/storage-pg/migrations/20250709142240_backfill_id_token_claims.sql b/crates/storage-pg/migrations/20250709142240_backfill_id_token_claims.sql new file mode 100644 index 000000000..c2fa067af --- /dev/null +++ b/crates/storage-pg/migrations/20250709142240_backfill_id_token_claims.sql @@ -0,0 +1,22 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This backfills the id_token_claims column in the upstream_oauth_authorization_sessions table +-- by decoding the id_token column and storing the decoded claims in the id_token_claims column. +UPDATE upstream_oauth_authorization_sessions +SET id_token_claims = CASE + WHEN id_token IS NULL OR id_token = '' THEN NULL + WHEN split_part(id_token, '.', 2) = '' THEN NULL + ELSE + (convert_from( + decode( + replace(replace(split_part(id_token, '.', 2), '-', '+'), '_', '/') || + repeat('=', (4 - length(split_part(id_token, '.', 2)) % 4) % 4), + 'base64' + ), + 'UTF8' + ))::JSONB +END +WHERE id_token IS NOT NULL AND id_token_claims IS NULL; diff --git a/crates/storage-pg/migrations/20250915092000_pgtrgm_extension.sql b/crates/storage-pg/migrations/20250915092000_pgtrgm_extension.sql new file mode 100644 index 000000000..2ebc26d25 --- /dev/null +++ b/crates/storage-pg/migrations/20250915092000_pgtrgm_extension.sql @@ -0,0 +1,10 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This enables the pg_trgm extension, which is used for search filters +-- Starting Posgres 16, this extension is marked as "trusted", meaning it can be +-- installed by non-superusers +CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/crates/storage-pg/migrations/20250915092635_users_username_trgm_idx.sql b/crates/storage-pg/migrations/20250915092635_users_username_trgm_idx.sql new file mode 100644 index 000000000..5f007d750 --- /dev/null +++ b/crates/storage-pg/migrations/20250915092635_users_username_trgm_idx.sql @@ -0,0 +1,10 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This adds an index on the username field for ILIKE '%search%' operations, +-- enabling fuzzy searches of usernames +CREATE INDEX CONCURRENTLY users_username_trgm_idx + ON users USING gin(username gin_trgm_ops); diff --git a/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql b/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql new file mode 100644 index 000000000..0e113b156 --- /dev/null +++ b/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql @@ -0,0 +1,68 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- A family of personal access tokens. This is a long-lived wrapper around the personal access tokens +-- themselves, allowing tokens to be regenerated whilst still retaining a persistent identifier for them. +CREATE TABLE personal_sessions ( + personal_session_id UUID NOT NULL PRIMARY KEY, + + -- If this session is owned by a user, the ID of the user. + -- Null otherwise. + owner_user_id UUID REFERENCES users(user_id), + + -- If this session is owned by an OAuth 2 Client (via Client Credentials grant), + -- the ID of the owning client. + -- Null otherwise. + owner_oauth2_client_id UUID REFERENCES oauth2_clients(oauth2_client_id), + + actor_user_id UUID NOT NULL REFERENCES users(user_id), + -- A human-readable label, intended to describe what the session is for. + human_name TEXT NOT NULL, + -- The OAuth2 scopes for the session, identical to OAuth2 sessions. + -- May include a device ID, but this is optional (sessions can be deviceless). + scope_list TEXT[] NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- If set, none of the tokens will be valid anymore. + revoked_at TIMESTAMP WITH TIME ZONE, + last_active_at TIMESTAMP WITH TIME ZONE, + last_active_ip INET, + + -- There must be exactly one owner. + CONSTRAINT personal_sessions_exactly_one_owner CHECK ((owner_user_id IS NULL) <> (owner_oauth2_client_id IS NULL)) +); + +-- Individual tokens. +CREATE TABLE personal_access_tokens ( + personal_access_token_id UUID NOT NULL PRIMARY KEY, + -- The session this access token belongs to. + personal_session_id UUID NOT NULL REFERENCES personal_sessions(personal_session_id), + -- SHA256 of the access token. + -- This is a lightweight measure to stop a database backup (or other + -- unauthorised read-only database access) escalating into real permissions + -- on a live system. + -- We could have used a hash with secret key, but this would no longer be + -- 'free' protection because it would need configuration (and introduce + -- potential issues with configuring it wrong). + -- This is currently inconsistent with other access token tables but it would + -- make sense to migrate those to match in the future. + access_token_sha256 BYTEA NOT NULL UNIQUE + -- A SHA256 hash is 32 bytes long + CHECK (octet_length(access_token_sha256) = 32), + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- If set, the token won't be valid after this time. + -- If not set, the token never automatically expires. + expires_at TIMESTAMP WITH TIME ZONE, + -- If set, this token is not valid anymore. + revoked_at TIMESTAMP WITH TIME ZONE +); + +-- Ensure we can only have one active personal access token in each family. +CREATE UNIQUE INDEX ON personal_access_tokens (personal_session_id) WHERE revoked_at IS NOT NULL; + +-- Add indices to satisfy foreign key backward checks +-- (and likely filter queries) +CREATE INDEX ON personal_sessions (owner_user_id) WHERE owner_user_id IS NOT NULL; +CREATE INDEX ON personal_sessions (owner_oauth2_client_id) WHERE owner_oauth2_client_id IS NOT NULL; +CREATE INDEX ON personal_sessions (actor_user_id); diff --git a/crates/storage-pg/migrations/20251023134634_personal_access_tokens_unique_fix.sql b/crates/storage-pg/migrations/20251023134634_personal_access_tokens_unique_fix.sql new file mode 100644 index 000000000..9274d16ac --- /dev/null +++ b/crates/storage-pg/migrations/20251023134634_personal_access_tokens_unique_fix.sql @@ -0,0 +1,14 @@ +-- Copyright 2025 Element Creations Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + + +-- Fix a faulty constraint. +-- The condition was incorrectly specified as `revoked_at IS NOT NULL` +-- when `revoked_at IS NULL` was meant. + +DROP INDEX personal_access_tokens_personal_session_id_idx; + +-- Ensure we can only have one active personal access token in each family. +CREATE UNIQUE INDEX ON personal_access_tokens (personal_session_id) WHERE revoked_at IS NULL; diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index cd5e40b53..4e12810cc 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -1,15 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing PostgreSQL implementation of repositories for sessions use async_trait::async_trait; -use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState, User}; +use mas_data_model::{ + Clock, CompatSession, CompatSessionState, Device, Session, SessionState, User, +}; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, app_session::{AppSession, AppSessionFilter, AppSessionRepository, AppSessionState}, compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, @@ -53,7 +55,9 @@ mod priv_ { use std::net::IpAddr; use chrono::{DateTime, Utc}; + use mas_storage::pagination::Node; use sea_query::enum_def; + use ulid::Ulid; use uuid::Uuid; #[derive(sqlx::FromRow)] @@ -75,6 +79,12 @@ mod priv_ { pub(super) last_active_at: Option>, pub(super) last_active_ip: Option, } + + impl Node for AppSessionLookup { + fn cursor(&self) -> Ulid { + self.cursor.into() + } + } } use priv_::{AppSessionLookup, AppSessionLookupIden}; @@ -82,7 +92,6 @@ use priv_::{AppSessionLookup, AppSessionLookupIden}; impl TryFrom for AppSession { type Error = DatabaseError; - #[allow(clippy::too_many_lines)] fn try_from(value: AppSessionLookup) -> Result { // This is annoying to do, but we have to match on all the fields to determine // whether it's a compat session or an oauth2 session @@ -257,7 +266,6 @@ fn split_filter( impl AppSessionRepository for PgAppSessionRepository<'_> { type Error = DatabaseError; - #[allow(clippy::too_many_lines)] #[tracing::instrument( name = "db.app_session.list", fields( @@ -499,17 +507,24 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { .instrument(span) .await?; - if let Ok(device_as_scope_token) = device.to_scope_token() { + if let Ok([stable_device_as_scope_token, unstable_device_as_scope_token]) = + device.to_scope_token() + { let span = tracing::info_span!( "db.app_session.finish_sessions_to_replace_device.oauth2_sessions", { DB_QUERY_TEXT } = tracing::field::Empty, ); sqlx::query!( " - UPDATE oauth2_sessions SET finished_at = $3 WHERE user_id = $1 AND $2 = ANY(scope_list) AND finished_at IS NULL + UPDATE oauth2_sessions + SET finished_at = $4 + WHERE user_id = $1 + AND ($2 = ANY(scope_list) OR $3 = ANY(scope_list)) + AND finished_at IS NULL ", Uuid::from(user.id), - device_as_scope_token.as_str(), + stable_device_as_scope_token.as_str(), + unstable_device_as_scope_token.as_str(), finished_at ) .record(&span) @@ -525,11 +540,10 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::Device; + use mas_data_model::{Device, clock::MockClock}; use mas_storage::{ Pagination, RepositoryAccess, app_session::{AppSession, AppSessionFilter}, - clock::MockClock, oauth2::OAuth2SessionRepository, }; use oauth2_types::{ @@ -586,13 +600,13 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); let active_list = repo.app_session().list(active, pagination).await.unwrap(); assert_eq!(active_list.edges.len(), 1); assert_eq!( - active_list.edges[0], + active_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); @@ -612,7 +626,7 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); let active_list = repo.app_session().list(active, pagination).await.unwrap(); @@ -620,7 +634,7 @@ mod tests { let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); assert_eq!(finished_list.edges.len(), 1); assert_eq!( - finished_list.edges[0], + finished_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); @@ -652,7 +666,10 @@ mod tests { .unwrap(); let device2 = Device::generate(&mut rng); - let scope = Scope::from_iter([OPENID, device2.to_scope_token().unwrap()]); + let scope: Scope = [OPENID] + .into_iter() + .chain(device2.to_scope_token().unwrap().into_iter()) + .collect(); // We're moving the clock forward by 1 minute between each session to ensure // we're getting consistent ordering in lists. @@ -671,25 +688,25 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 2); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); assert_eq!( - full_list.edges[1], + full_list.edges[1].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); let active_list = repo.app_session().list(active, pagination).await.unwrap(); assert_eq!(active_list.edges.len(), 1); assert_eq!( - active_list.edges[0], + active_list.edges[0].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); assert_eq!(finished_list.edges.len(), 1); assert_eq!( - finished_list.edges[0], + finished_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); @@ -707,11 +724,11 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 2); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); assert_eq!( - full_list.edges[1], + full_list.edges[1].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); @@ -721,11 +738,11 @@ mod tests { let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); assert_eq!(finished_list.edges.len(), 2); assert_eq!( - finished_list.edges[0], + finished_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); assert_eq!( - full_list.edges[1], + full_list.edges[1].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); @@ -735,7 +752,7 @@ mod tests { let list = repo.app_session().list(filter, pagination).await.unwrap(); assert_eq!(list.edges.len(), 1); assert_eq!( - list.edges[0], + list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); @@ -744,7 +761,7 @@ mod tests { let list = repo.app_session().list(filter, pagination).await.unwrap(); assert_eq!(list.edges.len(), 1); assert_eq!( - list.edges[0], + list.edges[0].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); diff --git a/crates/storage-pg/src/compat/access_token.rs b/crates/storage-pg/src/compat/access_token.rs index 852b0e934..8e68f0767 100644 --- a/crates/storage-pg/src/compat/access_token.rs +++ b/crates/storage-pg/src/compat/access_token.rs @@ -1,13 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use mas_data_model::{CompatAccessToken, CompatSession}; -use mas_storage::{Clock, compat::CompatAccessTokenRepository}; +use mas_data_model::{Clock, CompatAccessToken, CompatSession}; +use mas_storage::compat::CompatAccessTokenRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index 60332fd50..d42c9b1af 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing PostgreSQL implementation of repositories for the //! compatibility layer @@ -20,10 +20,9 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::Device; + use mas_data_model::{Clock, Device, clock::MockClock}; use mas_storage::{ - Clock, Pagination, RepositoryAccess, - clock::MockClock, + Pagination, RepositoryAccess, compat::{ CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionFilter, CompatSessionRepository, CompatSsoLoginFilter, @@ -93,14 +92,14 @@ mod tests { let full_list = repo.compat_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); - assert_eq!(full_list.edges[0].0.id, session.id); + assert_eq!(full_list.edges[0].node.0.id, session.id); let active_list = repo .compat_session() .list(active, pagination) .await .unwrap(); assert_eq!(active_list.edges.len(), 1); - assert_eq!(active_list.edges[0].0.id, session.id); + assert_eq!(active_list.edges[0].node.0.id, session.id); let finished_list = repo .compat_session() .list(finished, pagination) @@ -151,7 +150,7 @@ mod tests { .await .unwrap(); assert_eq!(list.edges.len(), 1); - let session_lookup = &list.edges[0].0; + let session_lookup = &list.edges[0].node.0; assert_eq!(session_lookup.id, session.id); assert_eq!(session_lookup.user_id, user.id); assert_eq!(session.device.as_ref().unwrap().as_str(), device_str); @@ -169,7 +168,7 @@ mod tests { let full_list = repo.compat_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); - assert_eq!(full_list.edges[0].0.id, session.id); + assert_eq!(full_list.edges[0].node.0.id, session.id); let active_list = repo .compat_session() .list(active, pagination) @@ -182,7 +181,7 @@ mod tests { .await .unwrap(); assert_eq!(finished_list.edges.len(), 1); - assert_eq!(finished_list.edges[0].0.id, session.id); + assert_eq!(finished_list.edges[0].node.0.id, session.id); // Reload the session and check again let session_lookup = repo @@ -261,14 +260,14 @@ mod tests { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].0.id, sso_login_session.id); + assert_eq!(list.edges[0].node.0.id, sso_login_session.id); let list = repo .compat_session() .list(unknown, pagination) .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].0.id, unknown_session.id); + assert_eq!(list.edges[0].node.0.id, unknown_session.id); // Check that combining the two filters works // At this point, there is one active SSO login session and one finished unknown @@ -697,7 +696,8 @@ mod tests { // List all logins let logins = repo.compat_sso_login().list(all, pagination).await.unwrap(); assert!(!logins.has_next_page); - assert_eq!(logins.edges, &[login.clone()]); + assert_eq!(logins.edges.len(), 1); + assert_eq!(logins.edges[0].node, login); // List the logins for the user let logins = repo @@ -706,7 +706,8 @@ mod tests { .await .unwrap(); assert!(!logins.has_next_page); - assert_eq!(logins.edges, &[login.clone()]); + assert_eq!(logins.edges.len(), 1); + assert_eq!(logins.edges[0].node, login); // List only the pending logins for the user let logins = repo @@ -733,6 +734,7 @@ mod tests { .await .unwrap(); assert!(!logins.has_next_page); - assert_eq!(logins.edges, &[login]); + assert_eq!(logins.edges.len(), 1); + assert_eq!(logins.edges[0].node, login); } } diff --git a/crates/storage-pg/src/compat/refresh_token.rs b/crates/storage-pg/src/compat/refresh_token.rs index 41188e010..5c6add046 100644 --- a/crates/storage-pg/src/compat/refresh_token.rs +++ b/crates/storage-pg/src/compat/refresh_token.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - CompatAccessToken, CompatRefreshToken, CompatRefreshTokenState, CompatSession, + Clock, CompatAccessToken, CompatRefreshToken, CompatRefreshTokenState, CompatSession, }; -use mas_storage::{Clock, compat::CompatRefreshTokenRepository}; +use mas_storage::compat::CompatRefreshTokenRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index 31f012477..0fb21c487 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -1,20 +1,21 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, - User, + BrowserSession, Clock, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, + Device, User, }; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, compat::{CompatSessionFilter, CompatSessionRepository}, + pagination::Node, }; use rand::RngCore; use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; @@ -27,7 +28,7 @@ use uuid::Uuid; use crate::{ DatabaseError, DatabaseInconsistencyError, filter::{Filter, StatementExt, StatementWithJoinsExt}, - iden::{CompatSessions, CompatSsoLogins}, + iden::{CompatSessions, CompatSsoLogins, UserSessions}, pagination::QueryBuilderExt, tracing::ExecuteExt, }; @@ -59,6 +60,12 @@ struct CompatSessionLookup { last_active_ip: Option, } +impl Node for CompatSessionLookup { + fn cursor(&self) -> Ulid { + self.compat_session_id.into() + } +} + impl From for CompatSession { fn from(value: CompatSessionLookup) -> Self { let id = value.compat_session_id.into(); @@ -106,6 +113,12 @@ struct CompatSessionAndSsoLoginLookup { compat_sso_login_exchanged_at: Option>, } +impl Node for CompatSessionAndSsoLoginLookup { + fn cursor(&self) -> Ulid { + self.compat_session_id.into() + } +} + impl TryFrom for (CompatSession, Option) { type Error = DatabaseInconsistencyError; @@ -190,6 +203,18 @@ impl Filter for CompatSessionFilter<'_> { Expr::col((CompatSessions::Table, CompatSessions::UserSessionId)) .eq(Uuid::from(browser_session.id)) })) + .add_option(self.browser_session_filter().map(|browser_session_filter| { + Expr::col((CompatSessions::Table, CompatSessions::UserSessionId)).in_subquery( + Query::select() + .expr(Expr::col(( + UserSessions::Table, + UserSessions::UserSessionId, + ))) + .apply_filter(browser_session_filter) + .from(UserSessions::Table) + .take(), + ) + })) .add_option(self.state().map(|state| { if state.is_active() { Expr::col((CompatSessions::Table, CompatSessions::FinishedAt)).is_null() diff --git a/crates/storage-pg/src/compat/sso_login.rs b/crates/storage-pg/src/compat/sso_login.rs index 2c794921b..43ad4bead 100644 --- a/crates/storage-pg/src/compat/sso_login.rs +++ b/crates/storage-pg/src/compat/sso_login.rs @@ -1,15 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, CompatSsoLoginState}; +use mas_data_model::{BrowserSession, Clock, CompatSession, CompatSsoLogin, CompatSsoLoginState}; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, compat::{CompatSsoLoginFilter, CompatSsoLoginRepository}, + pagination::Node, }; use rand::RngCore; use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; @@ -54,6 +55,12 @@ struct CompatSsoLoginLookup { compat_session_id: Option, } +impl Node for CompatSsoLoginLookup { + fn cursor(&self) -> Ulid { + self.compat_sso_login_id.into() + } +} + impl TryFrom for CompatSsoLogin { type Error = DatabaseInconsistencyError; diff --git a/crates/storage-pg/src/errors.rs b/crates/storage-pg/src/errors.rs index 80fb9fa66..4c50557ff 100644 --- a/crates/storage-pg/src/errors.rs +++ b/crates/storage-pg/src/errors.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use sqlx::postgres::PgQueryResult; use thiserror::Error; diff --git a/crates/storage-pg/src/filter.rs b/crates/storage-pg/src/filter.rs index e0771fc4e..d8bf3e930 100644 --- a/crates/storage-pg/src/filter.rs +++ b/crates/storage-pg/src/filter.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. /// A filter which can be applied to a query pub(crate) trait Filter { diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 6692c7a75..4e5a39139 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Table and column identifiers used by [`sea_query`] @@ -18,6 +18,18 @@ pub enum UserSessions { LastActiveIp, } +#[derive(sea_query::Iden)] +#[expect(dead_code)] +pub enum UserSessionAuthentications { + Table, + UserSessionAuthenticationId, + UserSessionId, + CreatedAt, + UserPasswordId, + #[iden = "upstream_oauth_authorization_session_id"] + UpstreamOAuthAuthorizationSessionId, +} + #[derive(sea_query::Iden)] pub enum Users { Table, @@ -27,6 +39,7 @@ pub enum Users { LockedAt, DeactivatedAt, CanRequestAdmin, + IsGuest, } #[derive(sea_query::Iden)] @@ -95,6 +108,35 @@ pub enum OAuth2Clients { IsStatic, } +#[derive(sea_query::Iden)] +#[iden = "personal_sessions"] +pub enum PersonalSessions { + Table, + PersonalSessionId, + OwnerUserId, + #[iden = "owner_oauth2_client_id"] + OwnerOAuth2ClientId, + ActorUserId, + HumanName, + ScopeList, + CreatedAt, + RevokedAt, + LastActiveAt, + LastActiveIp, +} + +#[derive(sea_query::Iden)] +#[iden = "personal_access_tokens"] +pub enum PersonalAccessTokens { + Table, + PersonalAccessTokenId, + PersonalSessionId, + // AccessTokenSha256, + CreatedAt, + ExpiresAt, + RevokedAt, +} + #[derive(sea_query::Iden)] #[iden = "upstream_oauth_providers"] pub enum UpstreamOAuthProviders { @@ -124,6 +166,7 @@ pub enum UpstreamOAuthProviders { TokenEndpointOverride, AuthorizationEndpointOverride, UserinfoEndpointOverride, + OnBackchannelLogout, } #[derive(sea_query::Iden)] @@ -140,6 +183,29 @@ pub enum UpstreamOAuthLinks { CreatedAt, } +#[derive(sea_query::Iden)] +#[iden = "upstream_oauth_authorization_sessions"] +pub enum UpstreamOAuthAuthorizationSessions { + Table, + #[iden = "upstream_oauth_authorization_session_id"] + UpstreamOAuthAuthorizationSessionId, + #[iden = "upstream_oauth_provider_id"] + UpstreamOAuthProviderId, + #[iden = "upstream_oauth_link_id"] + UpstreamOAuthLinkId, + State, + CodeChallengeVerifier, + Nonce, + IdToken, + IdTokenClaims, + ExtraCallbackParameters, + Userinfo, + CreatedAt, + CompletedAt, + ConsumedAt, + UnlinkedAt, +} + #[derive(sea_query::Iden)] pub enum UserRegistrationTokens { Table, diff --git a/crates/storage-pg/src/lib.rs b/crates/storage-pg/src/lib.rs index 30882cfa8..207235667 100644 --- a/crates/storage-pg/src/lib.rs +++ b/crates/storage-pg/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! An implementation of the storage traits for a PostgreSQL database //! @@ -22,7 +22,7 @@ //! # use async_trait::async_trait; //! # use ulid::Ulid; //! # use rand::RngCore; -//! # use mas_storage::Clock; +//! # use mas_data_model::Clock; //! # use mas_storage_pg::{DatabaseError, ExecuteExt}; //! # use sqlx::PgConnection; //! # use uuid::Uuid; @@ -165,6 +165,7 @@ use sqlx::migrate::Migrator; pub mod app_session; pub mod compat; pub mod oauth2; +pub mod personal; pub mod queue; pub mod upstream_oauth2; pub mod user; diff --git a/crates/storage-pg/src/oauth2/access_token.rs b/crates/storage-pg/src/oauth2/access_token.rs index de652739f..63790b77e 100644 --- a/crates/storage-pg/src/oauth2/access_token.rs +++ b/crates/storage-pg/src/oauth2/access_token.rs @@ -1,13 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use mas_data_model::{AccessToken, AccessTokenState, Session}; -use mas_storage::{Clock, oauth2::OAuth2AccessTokenRepository}; +use mas_data_model::{AccessToken, AccessTokenState, Clock, Session}; +use mas_storage::oauth2::OAuth2AccessTokenRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/oauth2/authorization_grant.rs b/crates/storage-pg/src/oauth2/authorization_grant.rs index 59c5c2338..b8af0e535 100644 --- a/crates/storage-pg/src/oauth2/authorization_grant.rs +++ b/crates/storage-pg/src/oauth2/authorization_grant.rs @@ -1,16 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, Pkce, Session, + AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, Clock, Pkce, Session, }; use mas_iana::oauth::PkceCodeChallengeMethod; -use mas_storage::{Clock, oauth2::OAuth2AuthorizationGrantRepository}; +use mas_storage::oauth2::OAuth2AuthorizationGrantRepository; use oauth2_types::{requests::ResponseMode, scope::Scope}; use rand::RngCore; use sqlx::PgConnection; @@ -60,7 +60,6 @@ struct GrantLookup { impl TryFrom for AuthorizationGrant { type Error = DatabaseInconsistencyError; - #[allow(clippy::too_many_lines)] fn try_from(value: GrantLookup) -> Result { let id = value.oauth2_authorization_grant_id.into(); let scope: Scope = value.scope.parse().map_err(|e| { diff --git a/crates/storage-pg/src/oauth2/client.rs b/crates/storage-pg/src/oauth2/client.rs index 60e1ebb54..8f7d24224 100644 --- a/crates/storage-pg/src/oauth2/client.rs +++ b/crates/storage-pg/src/oauth2/client.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{ collections::{BTreeMap, BTreeSet}, @@ -10,10 +10,10 @@ use std::{ }; use async_trait::async_trait; -use mas_data_model::{Client, JwksOrJwksUri}; +use mas_data_model::{Client, Clock, JwksOrJwksUri}; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use mas_jose::jwk::PublicJsonWebKeySet; -use mas_storage::{Clock, oauth2::OAuth2ClientRepository}; +use mas_storage::oauth2::OAuth2ClientRepository; use oauth2_types::{oidc::ApplicationType, requests::GrantType}; use opentelemetry_semantic_conventions::attribute::DB_QUERY_TEXT; use rand::RngCore; @@ -67,7 +67,6 @@ struct OAuth2ClientLookup { impl TryInto for OAuth2ClientLookup { type Error = DatabaseInconsistencyError; - #[allow(clippy::too_many_lines)] // TODO: refactor some of the field parsing fn try_into(self) -> Result { let id = Ulid::from(self.oauth2_client_id); @@ -416,7 +415,6 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { ), err, )] - #[allow(clippy::too_many_lines)] async fn add( &mut self, rng: &mut (dyn RngCore + Send), @@ -813,6 +811,49 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { .await?; } + // Delete any personal access tokens & sessions owned + // by the client + { + let span = info_span!( + "db.oauth2_client.delete_by_id.personal_access_tokens", + { DB_QUERY_TEXT } = tracing::field::Empty, + ); + + sqlx::query!( + r#" + DELETE FROM personal_access_tokens + WHERE personal_session_id IN ( + SELECT personal_session_id + FROM personal_sessions + WHERE owner_oauth2_client_id = $1 + ) + "#, + Uuid::from(id), + ) + .record(&span) + .execute(&mut *self.conn) + .instrument(span) + .await?; + } + { + let span = info_span!( + "db.oauth2_client.delete_by_id.personal_sessions", + { DB_QUERY_TEXT } = tracing::field::Empty, + ); + + sqlx::query!( + r#" + DELETE FROM personal_sessions + WHERE owner_oauth2_client_id = $1 + "#, + Uuid::from(id), + ) + .record(&span) + .execute(&mut *self.conn) + .instrument(span) + .await?; + } + // Now delete the client itself let res = sqlx::query!( r#" diff --git a/crates/storage-pg/src/oauth2/device_code_grant.rs b/crates/storage-pg/src/oauth2/device_code_grant.rs index ebed4d859..57c057503 100644 --- a/crates/storage-pg/src/oauth2/device_code_grant.rs +++ b/crates/storage-pg/src/oauth2/device_code_grant.rs @@ -1,18 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session}; -use mas_storage::{ - Clock, - oauth2::{OAuth2DeviceCodeGrantParams, OAuth2DeviceCodeGrantRepository}, -}; +use mas_data_model::{BrowserSession, Clock, DeviceCodeGrant, DeviceCodeGrantState, Session}; +use mas_storage::oauth2::{OAuth2DeviceCodeGrantParams, OAuth2DeviceCodeGrantRepository}; use oauth2_types::scope::Scope; use rand::RngCore; use sqlx::PgConnection; diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index 3f70fd5cc..bf741b5f2 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementations of the OAuth2-related //! repositories @@ -24,10 +24,9 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::AuthorizationCode; + use mas_data_model::{AuthorizationCode, Clock, clock::MockClock}; use mas_storage::{ - Clock, Pagination, - clock::MockClock, + Pagination, oauth2::{OAuth2DeviceCodeGrantParams, OAuth2SessionFilter, OAuth2SessionRepository}, }; use oauth2_types::{ @@ -512,10 +511,10 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 4); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); - assert_eq!(list.edges[2], session21); - assert_eq!(list.edges[3], session22); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); + assert_eq!(list.edges[2].node, session21); + assert_eq!(list.edges[3].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 4); @@ -528,8 +527,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session21); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session21); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -542,8 +541,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -558,7 +557,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session22); + assert_eq!(list.edges[0].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -571,8 +570,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session12); - assert_eq!(list.edges[1], session21); + assert_eq!(list.edges[0].node, session12); + assert_eq!(list.edges[1].node, session21); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -585,8 +584,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session22); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -599,7 +598,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session22); + assert_eq!(list.edges[0].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -614,7 +613,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session22); + assert_eq!(list.edges[0].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -627,7 +626,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session12); + assert_eq!(list.edges[0].node, session12); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -642,7 +641,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session21); + assert_eq!(list.edges[0].node, session21); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -656,10 +655,10 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 4); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); - assert_eq!(list.edges[2], session21); - assert_eq!(list.edges[3], session22); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); + assert_eq!(list.edges[2].node, session21); + assert_eq!(list.edges[3].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 4); // We should get all sessions with the "openid" and "email" scope @@ -672,8 +671,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); // Try combining the scope filter with the user filter @@ -686,7 +685,7 @@ mod tests { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session11); + assert_eq!(list.edges[0].node, session11); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); // Finish all sessions of a client in batch diff --git a/crates/storage-pg/src/oauth2/refresh_token.rs b/crates/storage-pg/src/oauth2/refresh_token.rs index 742d3ae68..5b49e4bd7 100644 --- a/crates/storage-pg/src/oauth2/refresh_token.rs +++ b/crates/storage-pg/src/oauth2/refresh_token.rs @@ -1,13 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{AccessToken, RefreshToken, RefreshTokenState, Session}; -use mas_storage::{Clock, oauth2::OAuth2RefreshTokenRepository}; +use mas_data_model::{AccessToken, Clock, RefreshToken, RefreshTokenState, Session}; +use mas_storage::oauth2::OAuth2RefreshTokenRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index d2fbd8130..072691a06 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -1,21 +1,25 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Client, Session, SessionState, User}; +use mas_data_model::{BrowserSession, Client, Clock, Session, SessionState, User}; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, + pagination::Node, }; use oauth2_types::scope::{Scope, ScopeToken}; use rand::RngCore; -use sea_query::{Expr, PgFunc, PostgresQueryBuilder, Query, enum_def, extension::postgres::PgExpr}; +use sea_query::{ + Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def, + extension::postgres::PgExpr, +}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; @@ -24,7 +28,7 @@ use uuid::Uuid; use crate::{ DatabaseError, DatabaseInconsistencyError, filter::{Filter, StatementExt}, - iden::{OAuth2Clients, OAuth2Sessions}, + iden::{OAuth2Clients, OAuth2Sessions, UserSessions}, pagination::QueryBuilderExt, tracing::ExecuteExt, }; @@ -58,6 +62,12 @@ struct OAuthSessionLookup { human_name: Option, } +impl Node for OAuthSessionLookup { + fn cursor(&self) -> Ulid { + self.oauth2_session_id.into() + } +} + impl TryFrom for Session { type Error = DatabaseInconsistencyError; @@ -126,12 +136,19 @@ impl Filter for OAuth2SessionFilter<'_> { .ne(Expr::all(static_clients)) } })) - .add_option(self.device().map(|device| { - if let Ok(scope_token) = device.to_scope_token() { - Expr::val(scope_token.to_string()).eq(PgFunc::any(Expr::col(( - OAuth2Sessions::Table, - OAuth2Sessions::ScopeList, - )))) + .add_option(self.device().map(|device| -> SimpleExpr { + if let Ok([stable_scope_token, unstable_scope_token]) = device.to_scope_token() { + Condition::any() + .add( + Expr::val(stable_scope_token.to_string()).eq(PgFunc::any(Expr::col(( + OAuth2Sessions::Table, + OAuth2Sessions::ScopeList, + )))), + ) + .add(Expr::val(unstable_scope_token.to_string()).eq(PgFunc::any( + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::ScopeList)), + ))) + .into() } else { // If the device ID can't be encoded as a scope token, match no rows Expr::val(false).into() @@ -141,6 +158,18 @@ impl Filter for OAuth2SessionFilter<'_> { Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserSessionId)) .eq(Uuid::from(browser_session.id)) })) + .add_option(self.browser_session_filter().map(|browser_session_filter| { + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserSessionId)).in_subquery( + Query::select() + .expr(Expr::col(( + UserSessions::Table, + UserSessions::UserSessionId, + ))) + .apply_filter(browser_session_filter) + .from(UserSessions::Table) + .take(), + ) + })) .add_option(self.state().map(|state| { if state.is_active() { Expr::col((OAuth2Sessions::Table, OAuth2Sessions::FinishedAt)).is_null() diff --git a/crates/storage-pg/src/pagination.rs b/crates/storage-pg/src/pagination.rs index 34f317a73..8e83c2372 100644 --- a/crates/storage-pg/src/pagination.rs +++ b/crates/storage-pg/src/pagination.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Utilities to manage paginated queries. diff --git a/crates/storage-pg/src/personal/access_token.rs b/crates/storage-pg/src/personal/access_token.rs new file mode 100644 index 000000000..db8164fe9 --- /dev/null +++ b/crates/storage-pg/src/personal/access_token.rs @@ -0,0 +1,253 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Clock, + personal::{PersonalAccessToken, session::PersonalSession}, +}; +use mas_storage::personal::PersonalAccessTokenRepository; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use sqlx::PgConnection; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{DatabaseError, tracing::ExecuteExt as _}; + +/// An implementation of [`PersonalAccessTokenRepository`] for a PostgreSQL +/// connection +pub struct PgPersonalAccessTokenRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgPersonalAccessTokenRepository<'c> { + /// Create a new [`PgPersonalAccessTokenRepository`] from an active + /// PostgreSQL connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +struct PersonalAccessTokenLookup { + personal_access_token_id: Uuid, + personal_session_id: Uuid, + created_at: DateTime, + expires_at: Option>, + revoked_at: Option>, +} + +impl From for PersonalAccessToken { + fn from(value: PersonalAccessTokenLookup) -> Self { + Self { + id: Ulid::from(value.personal_access_token_id), + session_id: Ulid::from(value.personal_session_id), + created_at: value.created_at, + expires_at: value.expires_at, + revoked_at: value.revoked_at, + } + } +} + +#[async_trait] +impl PersonalAccessTokenRepository for PgPersonalAccessTokenRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.personal_access_token.lookup", + skip_all, + fields( + db.query.text, + personal_access_token.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE personal_access_token_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.find_by_token", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error> { + let token_sha256 = Sha256::digest(access_token.as_bytes()).to_vec(); + + let res = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE access_token_sha256 = $1 + "#, + &token_sha256, + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.find_active_for_session", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find_active_for_session( + &mut self, + session: &PersonalSession, + ) -> Result, Self::Error> { + let res: Option = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE personal_session_id = $1 + AND revoked_at IS NULL + "#, + Uuid::from(session.id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.add", + skip_all, + fields( + db.query.text, + personal_access_token.id, + %session.id, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: &str, + expires_after: Option, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("personal_access_token.id", tracing::field::display(id)); + + let token_sha256 = Sha256::digest(access_token.as_bytes()).to_vec(); + + let expires_at = expires_after.map(|expires_after| created_at + expires_after); + + sqlx::query!( + r#" + INSERT INTO personal_access_tokens + (personal_access_token_id, personal_session_id, access_token_sha256, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5) + "#, + Uuid::from(id), + Uuid::from(session.id), + &token_sha256, + created_at, + expires_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(PersonalAccessToken { + id, + session_id: session.id, + created_at, + expires_at, + revoked_at: None, + }) + } + + #[tracing::instrument( + name = "db.personal_access_token.revoke", + skip_all, + fields( + db.query.text, + %access_token.id, + personal_session.id = %access_token.session_id, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + mut access_token: PersonalAccessToken, + ) -> Result { + let revoked_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE personal_access_tokens + SET revoked_at = $2 + WHERE personal_access_token_id = $1 + "#, + Uuid::from(access_token.id), + revoked_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + access_token.revoked_at = Some(revoked_at); + Ok(access_token) + } +} diff --git a/crates/storage-pg/src/personal/mod.rs b/crates/storage-pg/src/personal/mod.rs new file mode 100644 index 000000000..f540a6be3 --- /dev/null +++ b/crates/storage-pg/src/personal/mod.rs @@ -0,0 +1,422 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! A module containing the PostgreSQL implementations of the +//! Personal Access Token / Personal Session repositories + +mod access_token; +mod session; + +pub use access_token::PgPersonalAccessTokenRepository; +pub use session::PgPersonalSessionRepository; + +#[cfg(test)] +mod tests { + use chrono::Duration; + use mas_data_model::{ + Clock, Device, clock::MockClock, personal::session::PersonalSessionOwner, + }; + use mas_storage::{ + Pagination, RepositoryAccess, + personal::{ + PersonalAccessTokenRepository, PersonalSessionFilter, PersonalSessionRepository, + }, + user::UserRepository, + }; + use oauth2_types::scope::{OPENID, PROFILE, Scope}; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use sqlx::PgPool; + + use crate::PgRepository; + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_session_repository(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + // Create a user + let admin_user = repo + .user() + .add(&mut rng, &clock, "john".to_owned()) + .await + .unwrap(); + let bot_user = repo + .user() + .add(&mut rng, &clock, "marvin".to_owned()) + .await + .unwrap(); + + let all = PersonalSessionFilter::new().for_actor_user(&bot_user); + let active = all.active_only(); + let finished = all.finished_only(); + let pagination = Pagination::first(10); + + assert_eq!(repo.personal_session().count(all).await.unwrap(), 0); + assert_eq!(repo.personal_session().count(active).await.unwrap(), 0); + assert_eq!(repo.personal_session().count(finished).await.unwrap(), 0); + + // We start off with no sessions + let full_list = repo.personal_session().list(all, pagination).await.unwrap(); + assert!(full_list.edges.is_empty()); + let active_list = repo + .personal_session() + .list(active, pagination) + .await + .unwrap(); + assert!(active_list.edges.is_empty()); + let finished_list = repo + .personal_session() + .list(finished, pagination) + .await + .unwrap(); + assert!(finished_list.edges.is_empty()); + + // Start a personal session for that user + let device = Device::generate(&mut rng); + let scope: Scope = [OPENID, PROFILE] + .into_iter() + .chain(device.to_scope_token().unwrap()) + .collect(); + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + (&admin_user).into(), + &bot_user, + "Test Personal Session".to_owned(), + scope.clone(), + ) + .await + .unwrap(); + assert_eq!(session.owner, PersonalSessionOwner::User(admin_user.id)); + assert_eq!(session.actor_user_id, bot_user.id); + assert!(session.is_valid()); + assert!(!session.is_revoked()); + assert_eq!(session.scope, scope); + + assert_eq!(repo.personal_session().count(all).await.unwrap(), 1); + assert_eq!(repo.personal_session().count(active).await.unwrap(), 1); + assert_eq!(repo.personal_session().count(finished).await.unwrap(), 0); + + let full_list = repo.personal_session().list(all, pagination).await.unwrap(); + assert_eq!(full_list.edges.len(), 1); + assert_eq!(full_list.edges[0].node.0.id, session.id); + assert!(full_list.edges[0].node.0.is_valid()); + let active_list = repo + .personal_session() + .list(active, pagination) + .await + .unwrap(); + assert_eq!(active_list.edges.len(), 1); + assert_eq!(active_list.edges[0].node.0.id, session.id); + assert!(active_list.edges[0].node.0.is_valid()); + let finished_list = repo + .personal_session() + .list(finished, pagination) + .await + .unwrap(); + assert!(finished_list.edges.is_empty()); + + // Lookup the session and check it didn't change + let session_lookup = repo + .personal_session() + .lookup(session.id) + .await + .unwrap() + .expect("personal session not found"); + assert_eq!(session_lookup.id, session.id); + assert_eq!( + session_lookup.owner, + PersonalSessionOwner::User(admin_user.id) + ); + assert_eq!(session_lookup.actor_user_id, bot_user.id); + assert_eq!(session_lookup.scope, scope); + assert!(session_lookup.is_valid()); + assert!(!session_lookup.is_revoked()); + + // Revoke the session + let session = repo + .personal_session() + .revoke(&clock, session) + .await + .unwrap(); + assert!(!session.is_valid()); + assert!(session.is_revoked()); + + assert_eq!(repo.personal_session().count(all).await.unwrap(), 1); + assert_eq!(repo.personal_session().count(active).await.unwrap(), 0); + assert_eq!(repo.personal_session().count(finished).await.unwrap(), 1); + + let full_list = repo.personal_session().list(all, pagination).await.unwrap(); + assert_eq!(full_list.edges.len(), 1); + assert_eq!(full_list.edges[0].node.0.id, session.id); + let active_list = repo + .personal_session() + .list(active, pagination) + .await + .unwrap(); + assert!(active_list.edges.is_empty()); + let finished_list = repo + .personal_session() + .list(finished, pagination) + .await + .unwrap(); + assert_eq!(finished_list.edges.len(), 1); + assert_eq!(finished_list.edges[0].node.0.id, session.id); + assert!(finished_list.edges[0].node.0.is_revoked()); + + // Reload the session and check again + let session_lookup = repo + .personal_session() + .lookup(session.id) + .await + .unwrap() + .expect("personal session not found"); + assert!(!session_lookup.is_valid()); + assert!(session_lookup.is_revoked()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_session_revoke_bulk(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + let alice_user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + let bob_user = repo + .user() + .add(&mut rng, &clock, "bob".to_owned()) + .await + .unwrap(); + + let session1 = repo + .personal_session() + .add( + &mut rng, + &clock, + (&alice_user).into(), + &bob_user, + "Test Personal Session".to_owned(), + "openid".parse().unwrap(), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &clock, + &session1, + "mpt_hiss", + Some(Duration::days(42)), + ) + .await + .unwrap(); + + let session2 = repo + .personal_session() + .add( + &mut rng, + &clock, + (&bob_user).into(), + &bob_user, + "Test Personal Session".to_owned(), + "openid".parse().unwrap(), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, &clock, &session2, "mpt_meow", // No expiry + None, + ) + .await + .unwrap(); + + // Just one session without a token expiry time + assert_eq!( + repo.personal_session() + .revoke_bulk( + &clock, + PersonalSessionFilter::new() + .active_only() + .with_expires(false) + ) + .await + .unwrap(), + 1 + ); + + // Just one session with a token expiry time + assert_eq!( + repo.personal_session() + .revoke_bulk( + &clock, + PersonalSessionFilter::new() + .active_only() + .with_expires(true) + ) + .await + .unwrap(), + 1 + ); + + // No active sessions left + assert_eq!( + repo.personal_session() + .revoke_bulk(&clock, PersonalSessionFilter::new().active_only()) + .await + .unwrap(), + 0 + ); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_access_token_repository(pool: PgPool) { + const FIRST_TOKEN: &str = "first_access_token"; + const SECOND_TOKEN: &str = "second_access_token"; + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a user + let admin_user = repo + .user() + .add(&mut rng, &clock, "john".to_owned()) + .await + .unwrap(); + let bot_user = repo + .user() + .add(&mut rng, &clock, "marvin".to_owned()) + .await + .unwrap(); + + // Start a personal session for that user + let device = Device::generate(&mut rng); + let scope: Scope = [OPENID, PROFILE] + .into_iter() + .chain(device.to_scope_token().unwrap()) + .collect(); + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + (&admin_user).into(), + &bot_user, + "Test Personal Session".to_owned(), + scope, + ) + .await + .unwrap(); + + // Add an access token to that session + let token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + FIRST_TOKEN, + Some(Duration::try_minutes(1).unwrap()), + ) + .await + .unwrap(); + assert_eq!(token.session_id, session.id); + + // Commit the txn and grab a new transaction, to test a conflict + repo.save().await.unwrap(); + + { + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + // Adding the same token a second time should conflict + assert!( + repo.personal_access_token() + .add( + &mut rng, + &clock, + &session, + FIRST_TOKEN, + Some(Duration::try_minutes(1).unwrap()), + ) + .await + .is_err() + ); + repo.cancel().await.unwrap(); + } + + // Grab a new repo + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Looking up via ID works + let token_lookup = repo + .personal_access_token() + .lookup(token.id) + .await + .unwrap() + .expect("personal access token not found"); + assert_eq!(token.id, token_lookup.id); + assert_eq!(token_lookup.session_id, session.id); + + // Looking up via the token value works + let token_lookup = repo + .personal_access_token() + .find_by_token(FIRST_TOKEN) + .await + .unwrap() + .expect("personal access token not found"); + assert_eq!(token.id, token_lookup.id); + assert_eq!(token_lookup.session_id, session.id); + + // Token is currently valid + assert!(token.is_valid(clock.now())); + + clock.advance(Duration::try_minutes(1).unwrap()); + // Token should have expired + assert!(!token.is_valid(clock.now())); + + // Add a second access token, this time without expiration + let _token = repo + .personal_access_token() + .revoke(&clock, token) + .await + .unwrap(); + let token = repo + .personal_access_token() + .add(&mut rng, &clock, &session, SECOND_TOKEN, None) + .await + .unwrap(); + assert_eq!(token.session_id, session.id); + + // Token is currently valid + assert!(token.is_valid(clock.now())); + + // Revoke it + let _token = repo + .personal_access_token() + .revoke(&clock, token) + .await + .unwrap(); + + // Reload it + let token = repo + .personal_access_token() + .find_by_token(SECOND_TOKEN) + .await + .unwrap() + .expect("personal access token not found"); + + // Token is not valid anymore + assert!(!token.is_valid(clock.now())); + + repo.save().await.unwrap(); + } +} diff --git a/crates/storage-pg/src/personal/session.rs b/crates/storage-pg/src/personal/session.rs new file mode 100644 index 000000000..b4c330ecb --- /dev/null +++ b/crates/storage-pg/src/personal/session.rs @@ -0,0 +1,702 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Clock, User, + personal::{ + PersonalAccessToken, + session::{PersonalSession, PersonalSessionOwner, SessionState}, + }, +}; +use mas_storage::{ + Page, Pagination, + pagination::Node, + personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState}, +}; +use oauth2_types::scope::Scope; +use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT; +use rand::RngCore; +use sea_query::{ + Cond, Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def, + extension::postgres::PgExpr as _, +}; +use sea_query_binder::SqlxBinder as _; +use sqlx::PgConnection; +use tracing::{Instrument as _, info_span}; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{ + DatabaseError, + errors::DatabaseInconsistencyError, + filter::{Filter, StatementExt as _}, + iden::{PersonalAccessTokens, PersonalSessions}, + pagination::QueryBuilderExt as _, + tracing::ExecuteExt as _, +}; + +/// An implementation of [`PersonalSessionRepository`] for a PostgreSQL +/// connection +pub struct PgPersonalSessionRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgPersonalSessionRepository<'c> { + /// Create a new [`PgPersonalSessionRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +#[derive(sqlx::FromRow)] +#[enum_def] +struct PersonalSessionLookup { + personal_session_id: Uuid, + owner_user_id: Option, + owner_oauth2_client_id: Option, + actor_user_id: Uuid, + human_name: String, + scope_list: Vec, + created_at: DateTime, + revoked_at: Option>, + last_active_at: Option>, + last_active_ip: Option, +} + +impl Node for PersonalSessionLookup { + fn cursor(&self) -> Ulid { + self.personal_session_id.into() + } +} + +impl TryFrom for PersonalSession { + type Error = DatabaseInconsistencyError; + + fn try_from(value: PersonalSessionLookup) -> Result { + let id = Ulid::from(value.personal_session_id); + let scope: Result = value.scope_list.iter().map(|s| s.parse()).collect(); + let scope = scope.map_err(|e| { + DatabaseInconsistencyError::on("personal_sessions") + .column("scope") + .row(id) + .source(e) + })?; + + let state = match value.revoked_at { + None => SessionState::Valid, + Some(revoked_at) => SessionState::Revoked { revoked_at }, + }; + + let owner = match (value.owner_user_id, value.owner_oauth2_client_id) { + (Some(owner_user_id), None) => PersonalSessionOwner::User(Ulid::from(owner_user_id)), + (None, Some(owner_oauth2_client_id)) => { + PersonalSessionOwner::OAuth2Client(Ulid::from(owner_oauth2_client_id)) + } + _ => { + // should be impossible (CHECK constraint in Postgres prevents it) + return Err(DatabaseInconsistencyError::on("personal_sessions") + .column("owner_user_id, owner_oauth2_client_id") + .row(id)); + } + }; + + Ok(PersonalSession { + id, + state, + owner, + actor_user_id: Ulid::from(value.actor_user_id), + human_name: value.human_name, + scope, + created_at: value.created_at, + last_active_at: value.last_active_at, + last_active_ip: value.last_active_ip, + }) + } +} + +#[derive(sqlx::FromRow)] +#[enum_def] +struct PersonalSessionAndAccessTokenLookup { + personal_session_id: Uuid, + owner_user_id: Option, + owner_oauth2_client_id: Option, + actor_user_id: Uuid, + human_name: String, + scope_list: Vec, + created_at: DateTime, + revoked_at: Option>, + last_active_at: Option>, + last_active_ip: Option, + + // tokens + personal_access_token_id: Option, + token_created_at: Option>, + token_expires_at: Option>, +} + +impl Node for PersonalSessionAndAccessTokenLookup { + fn cursor(&self) -> Ulid { + self.personal_session_id.into() + } +} + +impl TryFrom + for (PersonalSession, Option) +{ + type Error = DatabaseInconsistencyError; + + fn try_from(value: PersonalSessionAndAccessTokenLookup) -> Result { + let session = PersonalSession::try_from(PersonalSessionLookup { + personal_session_id: value.personal_session_id, + owner_user_id: value.owner_user_id, + owner_oauth2_client_id: value.owner_oauth2_client_id, + actor_user_id: value.actor_user_id, + human_name: value.human_name, + scope_list: value.scope_list, + created_at: value.created_at, + revoked_at: value.revoked_at, + last_active_at: value.last_active_at, + last_active_ip: value.last_active_ip, + })?; + + let token_opt = if let Some(id) = value.personal_access_token_id { + let id = Ulid::from(id); + Some(PersonalAccessToken { + id, + session_id: session.id, + // should not be possible + created_at: value.token_created_at.ok_or( + DatabaseInconsistencyError::on("personal_sessions") + .column("created_at") + .row(id), + )?, + expires_at: value.token_expires_at, + revoked_at: None, + }) + } else { + None + }; + + Ok((session, token_opt)) + } +} + +#[async_trait] +impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.personal_session.lookup", + skip_all, + fields( + db.query.text, + session.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + PersonalSessionLookup, + r#" + SELECT personal_session_id + , owner_user_id + , owner_oauth2_client_id + , actor_user_id + , scope_list + , created_at + , revoked_at + , human_name + , last_active_at + , last_active_ip as "last_active_ip: IpAddr" + FROM personal_sessions + + WHERE personal_session_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(session) = res else { return Ok(None) }; + + Ok(Some(session.try_into()?)) + } + + #[tracing::instrument( + name = "db.personal_session.add", + skip_all, + fields( + db.query.text, + session.id, + session.scope = %scope, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner: PersonalSessionOwner, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("session.id", tracing::field::display(id)); + + let scope_list: Vec = scope.iter().map(|s| s.as_str().to_owned()).collect(); + + let (owner_user_id, owner_oauth2_client_id) = match owner { + PersonalSessionOwner::User(ulid) => (Some(Uuid::from(ulid)), None), + PersonalSessionOwner::OAuth2Client(ulid) => (None, Some(Uuid::from(ulid))), + }; + + sqlx::query!( + r#" + INSERT INTO personal_sessions + ( personal_session_id + , owner_user_id + , owner_oauth2_client_id + , actor_user_id + , human_name + , scope_list + , created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + Uuid::from(id), + owner_user_id, + owner_oauth2_client_id, + Uuid::from(actor_user.id), + &human_name, + &scope_list, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(PersonalSession { + id, + state: SessionState::Valid, + owner, + actor_user_id: actor_user.id, + human_name, + scope, + created_at, + last_active_at: None, + last_active_ip: None, + }) + } + + #[tracing::instrument( + name = "db.personal_session.revoke", + skip_all, + fields( + db.query.text, + %session.id, + %session.scope, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + session: PersonalSession, + ) -> Result { + let revoked_at = clock.now(); + + { + // Revoke dependent PATs + let span = info_span!( + "db.personal_session.revoke.tokens", + { DB_QUERY_TEXT } = tracing::field::Empty, + ); + + sqlx::query!( + r#" + UPDATE personal_access_tokens + SET revoked_at = $2 + WHERE personal_session_id = $1 AND revoked_at IS NULL + "#, + Uuid::from(session.id), + revoked_at, + ) + .record(&span) + .execute(&mut *self.conn) + .instrument(span) + .await?; + } + + let res = sqlx::query!( + r#" + UPDATE personal_sessions + SET revoked_at = $2 + WHERE personal_session_id = $1 + "#, + Uuid::from(session.id), + revoked_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + session + .finish(revoked_at) + .map_err(DatabaseError::to_invalid_operation) + } + + #[tracing::instrument( + name = "db.personal_session.revoke_bulk", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn revoke_bulk( + &mut self, + clock: &dyn Clock, + filter: PersonalSessionFilter<'_>, + ) -> Result { + let revoked_at = clock.now(); + + let (sql, arguments) = Query::update() + .table(PersonalSessions::Table) + .value(PersonalSessions::RevokedAt, revoked_at) + .and_where( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)) + // Because filters apply to both the session and access token tables, + // Use a subquery to make it possible to use a JOIN + // onto the personal access token table. + .in_subquery( + Query::select() + .expr(Expr::col(( + PersonalSessions::Table, + PersonalSessions::PersonalSessionId, + ))) + .from(PersonalSessions::Table) + .left_join( + PersonalAccessTokens::Table, + Cond::all() + // Match session ID + .add( + Expr::col(( + PersonalSessions::Table, + PersonalSessions::PersonalSessionId, + )) + .eq(Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalSessionId, + ))), + ) + // Only choose the active access token for each session + .add( + Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::RevokedAt, + )) + .is_null(), + ), + ) + .apply_filter(filter) + .take(), + ), + ) + .build_sqlx(PostgresQueryBuilder); + + let res = sqlx::query_with(&sql, arguments) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(res.rows_affected().try_into().unwrap_or(usize::MAX)) + } + + #[tracing::instrument( + name = "db.personal_session.list", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn list( + &mut self, + filter: PersonalSessionFilter<'_>, + pagination: Pagination, + ) -> Result)>, Self::Error> { + let (sql, arguments) = Query::select() + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)), + PersonalSessionAndAccessTokenLookupIden::PersonalSessionId, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)), + PersonalSessionAndAccessTokenLookupIden::OwnerUserId, + ) + .expr_as( + Expr::col(( + PersonalSessions::Table, + PersonalSessions::OwnerOAuth2ClientId, + )), + PersonalSessionAndAccessTokenLookupIden::OwnerOauth2ClientId, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)), + PersonalSessionAndAccessTokenLookupIden::ActorUserId, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)), + PersonalSessionAndAccessTokenLookupIden::HumanName, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)), + PersonalSessionAndAccessTokenLookupIden::ScopeList, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)), + PersonalSessionAndAccessTokenLookupIden::CreatedAt, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)), + PersonalSessionAndAccessTokenLookupIden::RevokedAt, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)), + PersonalSessionAndAccessTokenLookupIden::LastActiveAt, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)), + PersonalSessionAndAccessTokenLookupIden::LastActiveIp, + ) + .expr_as( + Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalAccessTokenId, + )), + PersonalSessionAndAccessTokenLookupIden::PersonalAccessTokenId, + ) + .expr_as( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::CreatedAt)), + PersonalSessionAndAccessTokenLookupIden::TokenCreatedAt, + ) + .expr_as( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)), + PersonalSessionAndAccessTokenLookupIden::TokenExpiresAt, + ) + .from(PersonalSessions::Table) + .left_join( + PersonalAccessTokens::Table, + Cond::all() + // Match session ID + .add( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)) + .eq(Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalSessionId, + ))), + ) + // Only choose the active access token for each session + .add( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt)) + .is_null(), + ), + ) + .apply_filter(filter) + .generate_pagination( + (PersonalSessions::Table, PersonalSessions::PersonalSessionId), + pagination, + ) + .build_sqlx(PostgresQueryBuilder); + + let edges: Vec = sqlx::query_as_with(&sql, arguments) + .traced() + .fetch_all(&mut *self.conn) + .await?; + + let page = pagination.process(edges).try_map(TryFrom::try_from)?; + + Ok(page) + } + + #[tracing::instrument( + name = "db.personal_session.count", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result { + let (sql, arguments) = Query::select() + .expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count()) + .from(PersonalSessions::Table) + .left_join( + PersonalAccessTokens::Table, + Cond::all() + // Match session ID + .add( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)) + .eq(Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalSessionId, + ))), + ) + // Only choose the active access token for each session + .add( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt)) + .is_null(), + ), + ) + .apply_filter(filter) + .build_sqlx(PostgresQueryBuilder); + + let count: i64 = sqlx::query_scalar_with(&sql, arguments) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count + .try_into() + .map_err(DatabaseError::to_invalid_operation) + } + + #[tracing::instrument( + name = "db.personal_session.record_batch_activity", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn record_batch_activity( + &mut self, + mut activities: Vec<(Ulid, DateTime, Option)>, + ) -> Result<(), Self::Error> { + // Sort the activity by ID, so that when batching the updates, Postgres + // locks the rows in a stable order, preventing deadlocks + activities.sort_unstable(); + let mut ids = Vec::with_capacity(activities.len()); + let mut last_activities = Vec::with_capacity(activities.len()); + let mut ips = Vec::with_capacity(activities.len()); + + for (id, last_activity, ip) in activities { + ids.push(Uuid::from(id)); + last_activities.push(last_activity); + ips.push(ip); + } + + let res = sqlx::query!( + r#" + UPDATE personal_sessions + SET last_active_at = GREATEST(t.last_active_at, personal_sessions.last_active_at) + , last_active_ip = COALESCE(t.last_active_ip, personal_sessions.last_active_ip) + FROM ( + SELECT * + FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[]) + AS t(personal_session_id, last_active_at, last_active_ip) + ) AS t + WHERE personal_sessions.personal_session_id = t.personal_session_id + "#, + &ids, + &last_activities, + &ips as &[Option], + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, ids.len().try_into().unwrap_or(u64::MAX))?; + + Ok(()) + } +} + +impl Filter for PersonalSessionFilter<'_> { + fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { + sea_query::Condition::all() + .add_option(self.owner_user().map(|user| { + Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)) + .eq(Uuid::from(user.id)) + })) + .add_option(self.owner_oauth2_client().map(|client| { + Expr::col(( + PersonalSessions::Table, + PersonalSessions::OwnerOAuth2ClientId, + )) + .eq(Uuid::from(client.id)) + })) + .add_option(self.actor_user().map(|user| { + Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)) + .eq(Uuid::from(user.id)) + })) + .add_option(self.device().map(|device| -> SimpleExpr { + if let Ok([stable_scope_token, unstable_scope_token]) = device.to_scope_token() { + Condition::any() + .add( + Expr::val(stable_scope_token.to_string()).eq(PgFunc::any(Expr::col(( + PersonalSessions::Table, + PersonalSessions::ScopeList, + )))), + ) + .add(Expr::val(unstable_scope_token.to_string()).eq(PgFunc::any( + Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)), + ))) + .into() + } else { + // If the device ID can't be encoded as a scope token, match no rows + Expr::val(false).into() + } + })) + .add_option(self.state().map(|state| match state { + PersonalSessionState::Active => { + Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)).is_null() + } + PersonalSessionState::Revoked => { + Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)).is_not_null() + } + })) + .add_option(self.scope().map(|scope| { + let scope: Vec = scope.iter().map(|s| s.as_str().to_owned()).collect(); + Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)).contains(scope) + })) + .add_option(self.last_active_before().map(|last_active_before| { + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)) + .lt(last_active_before) + })) + .add_option(self.last_active_after().map(|last_active_after| { + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)) + .gt(last_active_after) + })) + .add_option(self.expires_before().map(|expires_before| { + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)) + .lt(expires_before) + })) + .add_option(self.expires_after().map(|expires_after| { + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)) + .gt(expires_after) + })) + .add_option(self.expires().map(|expires| { + let column = + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)); + + if expires { + column.is_not_null() + } else { + column.is_null() + } + })) + } +} diff --git a/crates/storage-pg/src/policy_data.rs b/crates/storage-pg/src/policy_data.rs index 65615b348..3bcb34d61 100644 --- a/crates/storage-pg/src/policy_data.rs +++ b/crates/storage-pg/src/policy_data.rs @@ -1,14 +1,14 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the policy data //! storage. use async_trait::async_trait; -use mas_data_model::PolicyData; -use mas_storage::{Clock, policy_data::PolicyDataRepository}; +use mas_data_model::{Clock, PolicyData}; +use mas_storage::policy_data::PolicyDataRepository; use rand::RngCore; use serde_json::Value; use sqlx::{PgConnection, types::Json}; @@ -151,7 +151,8 @@ impl PolicyDataRepository for PgPolicyDataRepository<'_> { #[cfg(test)] mod tests { - use mas_storage::{clock::MockClock, policy_data::PolicyDataRepository}; + use mas_data_model::clock::MockClock; + use mas_storage::policy_data::PolicyDataRepository; use rand::SeedableRng; use rand_chacha::ChaChaRng; use serde_json::json; diff --git a/crates/storage-pg/src/queue/job.rs b/crates/storage-pg/src/queue/job.rs index 6a7d81e01..a7f9e5591 100644 --- a/crates/storage-pg/src/queue/job.rs +++ b/crates/storage-pg/src/queue/job.rs @@ -1,17 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the //! [`QueueJobRepository`]. use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use mas_storage::{ - Clock, - queue::{Job, QueueJobRepository, Worker}, -}; +use mas_data_model::Clock; +use mas_storage::queue::{Job, QueueJobRepository, Worker}; use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT; use rand::RngCore; use sqlx::PgConnection; diff --git a/crates/storage-pg/src/queue/mod.rs b/crates/storage-pg/src/queue/mod.rs index 1c00e1d7d..d3570e59f 100644 --- a/crates/storage-pg/src/queue/mod.rs +++ b/crates/storage-pg/src/queue/mod.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the job queue diff --git a/crates/storage-pg/src/queue/schedule.rs b/crates/storage-pg/src/queue/schedule.rs index afd09a8e3..fd28ef141 100644 --- a/crates/storage-pg/src/queue/schedule.rs +++ b/crates/storage-pg/src/queue/schedule.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the //! [`QueueScheduleRepository`]. diff --git a/crates/storage-pg/src/queue/worker.rs b/crates/storage-pg/src/queue/worker.rs index 6c4a8e6c5..c7dbc1f87 100644 --- a/crates/storage-pg/src/queue/worker.rs +++ b/crates/storage-pg/src/queue/worker.rs @@ -1,17 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the //! [`QueueWorkerRepository`]. use async_trait::async_trait; use chrono::Duration; -use mas_storage::{ - Clock, - queue::{QueueWorkerRepository, Worker}, -}; +use mas_data_model::Clock; +use mas_storage::queue::{QueueWorkerRepository, Worker}; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 8dc02b9bb..210d66a02 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::ops::{Deref, DerefMut}; @@ -20,6 +20,7 @@ use mas_storage::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::PersonalSessionRepository, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -47,6 +48,7 @@ use crate::{ PgOAuth2ClientRepository, PgOAuth2DeviceCodeGrantRepository, PgOAuth2RefreshTokenRepository, PgOAuth2SessionRepository, }, + personal::{PgPersonalAccessTokenRepository, PgPersonalSessionRepository}, policy_data::PgPolicyDataRepository, queue::{ job::PgQueueJobRepository, schedule::PgQueueScheduleRepository, @@ -328,6 +330,19 @@ where Box::new(PgCompatRefreshTokenRepository::new(self.conn.as_mut())) } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> + { + Box::new(PgPersonalAccessTokenRepository::new(self.conn.as_mut())) + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgPersonalSessionRepository::new(self.conn.as_mut())) + } + fn queue_worker<'c>(&'c mut self) -> Box + 'c> { Box::new(PgQueueWorkerRepository::new(self.conn.as_mut())) } diff --git a/crates/storage-pg/src/telemetry.rs b/crates/storage-pg/src/telemetry.rs index 93c74e74f..4771f22cc 100644 --- a/crates/storage-pg/src/telemetry.rs +++ b/crates/storage-pg/src/telemetry.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; diff --git a/crates/storage-pg/src/tracing.rs b/crates/storage-pg/src/tracing.rs index 137bf036c..4cab1fd79 100644 --- a/crates/storage-pg/src/tracing.rs +++ b/crates/storage-pg/src/tracing.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use opentelemetry_semantic_conventions::attribute::DB_QUERY_TEXT; use tracing::Span; diff --git a/crates/storage-pg/src/upstream_oauth2/link.rs b/crates/storage-pg/src/upstream_oauth2/link.rs index 390029f1f..c43dd8a18 100644 --- a/crates/storage-pg/src/upstream_oauth2/link.rs +++ b/crates/storage-pg/src/upstream_oauth2/link.rs @@ -1,14 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{UpstreamOAuthLink, UpstreamOAuthProvider, User}; +use mas_data_model::{Clock, UpstreamOAuthLink, UpstreamOAuthProvider, User}; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, + pagination::Node, upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository}, }; use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT; @@ -53,6 +54,12 @@ struct LinkLookup { created_at: DateTime, } +impl Node for LinkLookup { + fn cursor(&self) -> Ulid { + self.upstream_oauth_link_id.into() + } +} + impl From for UpstreamOAuthLink { fn from(value: LinkLookup) -> Self { UpstreamOAuthLink { diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index a5cda570b..d98e840b6 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the repositories //! related to the upstream OAuth 2.0 providers @@ -20,16 +20,16 @@ pub use self::{ mod tests { use chrono::Duration; use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderTokenAuthMethod, clock::MockClock, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_storage::{ Pagination, RepositoryAccess, - clock::MockClock, upstream_oauth2::{ UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository, UpstreamOAuthProviderFilter, UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository, - UpstreamOAuthSessionRepository, + UpstreamOAuthSessionFilter, UpstreamOAuthSessionRepository, }, user::UserRepository, }; @@ -78,6 +78,7 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -152,7 +153,7 @@ mod tests { let session = repo .upstream_oauth_session() - .complete_with_link(&clock, session, &link, None, None, None) + .complete_with_link(&clock, session, &link, None, None, None, None) .await .unwrap(); // Reload the session @@ -205,8 +206,8 @@ mod tests { assert!(!links.has_previous_page); assert!(!links.has_next_page); assert_eq!(links.edges.len(), 1); - assert_eq!(links.edges[0].id, link.id); - assert_eq!(links.edges[0].user_id, Some(user.id)); + assert_eq!(links.edges[0].node.id, link.id); + assert_eq!(links.edges[0].node.user_id, Some(user.id)); assert_eq!(repo.upstream_oauth_link().count(filter).await.unwrap(), 1); @@ -262,6 +263,29 @@ mod tests { 1 ); + // Test listing and counting sessions + let session_filter = UpstreamOAuthSessionFilter::new().for_provider(&provider); + + // Count the sessions for the provider + let session_count = repo + .upstream_oauth_session() + .count(session_filter) + .await + .unwrap(); + assert_eq!(session_count, 1); + + // List the sessions for the provider + let session_page = repo + .upstream_oauth_session() + .list(session_filter, Pagination::first(10)) + .await + .unwrap(); + + assert_eq!(session_page.edges.len(), 1); + assert_eq!(session_page.edges[0].node.id, session.id); + assert!(!session_page.has_next_page); + assert!(!session_page.has_previous_page); + // Try deleting the provider repo.upstream_oauth_provider() .delete(provider) @@ -326,6 +350,7 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -349,7 +374,7 @@ mod tests { // It returned the first 10 items assert!(page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[..10]); // Getting the same page with the "enabled only" filter should return the same @@ -371,7 +396,7 @@ mod tests { // It returned the next 10 items assert!(!page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[10..]); // Lookup the last 10 items @@ -383,7 +408,7 @@ mod tests { // It returned the last 10 items assert!(page.has_previous_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[10..]); // Lookup the previous 10 items @@ -395,7 +420,7 @@ mod tests { // It returned the previous 10 items assert!(!page.has_previous_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[..10]); // Lookup 10 items between two IDs @@ -407,7 +432,7 @@ mod tests { // It returned the items in between assert!(!page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[6..8]); // There should not be any disabled providers @@ -423,4 +448,213 @@ mod tests { .is_empty() ); } + + /// Test that the pagination works as expected in the upstream OAuth + /// session repository + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_session_repository_pagination(pool: PgPool) { + let scope = Scope::from_iter([OPENID]); + + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + // Create a provider + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &clock, + UpstreamOAuthProviderParams { + issuer: Some("https://example.com/".to_owned()), + human_name: None, + brand_name: None, + scope, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + token_endpoint_signing_alg: None, + client_id: "client-id".to_owned(), + encrypted_client_secret: None, + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + token_endpoint_override: None, + authorization_endpoint_override: None, + userinfo_endpoint_override: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + }, + ) + .await + .unwrap(); + + let filter = UpstreamOAuthSessionFilter::new().for_provider(&provider); + + // Count the number of sessions before we start + assert_eq!( + repo.upstream_oauth_session().count(filter).await.unwrap(), + 0 + ); + + let mut links = Vec::with_capacity(3); + for subject in ["alice", "bob", "charlie"] { + let link = repo + .upstream_oauth_link() + .add(&mut rng, &clock, &provider, subject.to_owned(), None) + .await + .unwrap(); + links.push(link); + } + + let mut ids = Vec::with_capacity(20); + let sids = ["one", "two"].into_iter().cycle(); + // Create 20 sessions + for (idx, (link, sid)) in links.iter().cycle().zip(sids).enumerate().take(20) { + let state = format!("state-{idx}"); + let session = repo + .upstream_oauth_session() + .add(&mut rng, &clock, &provider, state, None, None) + .await + .unwrap(); + let id_token_claims = serde_json::json!({ + "sub": link.subject, + "sid": sid, + "aud": provider.client_id, + "iss": "https://example.com/", + }); + let session = repo + .upstream_oauth_session() + .complete_with_link( + &clock, + session, + link, + None, + Some(id_token_claims), + None, + None, + ) + .await + .unwrap(); + ids.push(session.id); + clock.advance(Duration::microseconds(10 * 1000 * 1000)); + } + + // Now we have 20 sessions + assert_eq!( + repo.upstream_oauth_session().count(filter).await.unwrap(), + 20 + ); + + // Lookup the first 10 items + let page = repo + .upstream_oauth_session() + .list(filter, Pagination::first(10)) + .await + .unwrap(); + + // It returned the first 10 items + assert!(page.has_next_page); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); + assert_eq!(&edge_ids, &ids[..10]); + + // Lookup the next 10 items + let page = repo + .upstream_oauth_session() + .list(filter, Pagination::first(10).after(ids[9])) + .await + .unwrap(); + + // It returned the next 10 items + assert!(!page.has_next_page); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); + assert_eq!(&edge_ids, &ids[10..]); + + // Lookup the last 10 items + let page = repo + .upstream_oauth_session() + .list(filter, Pagination::last(10)) + .await + .unwrap(); + + // It returned the last 10 items + assert!(page.has_previous_page); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); + assert_eq!(&edge_ids, &ids[10..]); + + // Lookup the previous 10 items + let page = repo + .upstream_oauth_session() + .list(filter, Pagination::last(10).before(ids[10])) + .await + .unwrap(); + + // It returned the previous 10 items + assert!(!page.has_previous_page); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); + assert_eq!(&edge_ids, &ids[..10]); + + // Lookup 5 items between two IDs + let page = repo + .upstream_oauth_session() + .list(filter, Pagination::first(10).after(ids[5]).before(ids[11])) + .await + .unwrap(); + + // It returned the items in between + assert!(!page.has_next_page); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); + assert_eq!(&edge_ids, &ids[6..11]); + + // Check the sub/sid filters + assert_eq!( + repo.upstream_oauth_session() + .count(filter.with_sub_claim("alice").with_sid_claim("one")) + .await + .unwrap(), + 4 + ); + assert_eq!( + repo.upstream_oauth_session() + .count(filter.with_sub_claim("bob").with_sid_claim("two")) + .await + .unwrap(), + 4 + ); + + let page = repo + .upstream_oauth_session() + .list( + filter.with_sub_claim("alice").with_sid_claim("one"), + Pagination::first(10), + ) + .await + .unwrap(); + assert_eq!(page.edges.len(), 4); + for edge in page.edges { + assert_eq!( + edge.node + .id_token_claims() + .unwrap() + .get("sub") + .unwrap() + .as_str(), + Some("alice") + ); + assert_eq!( + edge.node + .id_token_claims() + .unwrap() + .get("sid") + .unwrap() + .as_str(), + Some("one") + ); + } + } } diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 879d7c658..caade738d 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -1,14 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports}; +use mas_data_model::{Clock, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports}; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, + pagination::Node, upstream_oauth2::{ UpstreamOAuthProviderFilter, UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository, }, @@ -71,12 +72,18 @@ struct ProviderLookup { response_mode: Option, additional_parameters: Option>>, forward_login_hint: bool, + on_backchannel_logout: String, +} + +impl Node for ProviderLookup { + fn cursor(&self) -> Ulid { + self.upstream_oauth_provider_id.into() + } } impl TryFrom for UpstreamOAuthProvider { type Error = DatabaseInconsistencyError; - #[allow(clippy::too_many_lines)] fn try_from(value: ProviderLookup) -> Result { let id = value.upstream_oauth_provider_id.into(); let scope = value.scope.parse().map_err(|e| { @@ -194,6 +201,13 @@ impl TryFrom for UpstreamOAuthProvider { .map(|Json(x)| x) .unwrap_or_default(); + let on_backchannel_logout = value.on_backchannel_logout.parse().map_err(|e| { + DatabaseInconsistencyError::on("upstream_oauth_providers") + .column("on_backchannel_logout") + .row(id) + .source(e) + })?; + Ok(UpstreamOAuthProvider { id, issuer: value.issuer, @@ -219,6 +233,7 @@ impl TryFrom for UpstreamOAuthProvider { response_mode, additional_authorization_parameters, forward_login_hint: value.forward_login_hint, + on_backchannel_logout, }) } } @@ -277,7 +292,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, additional_parameters as "additional_parameters: Json>", - forward_login_hint + forward_login_hint, + on_backchannel_logout FROM upstream_oauth_providers WHERE upstream_oauth_provider_id = $1 "#, @@ -340,9 +356,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, forward_login_hint, + on_backchannel_logout, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23) "#, Uuid::from(id), params.issuer.as_deref(), @@ -380,6 +398,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), params.forward_login_hint, + params.on_backchannel_logout.as_str(), created_at, ) .traced() @@ -410,6 +429,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode: params.pkce_mode, response_mode: params.response_mode, additional_authorization_parameters: params.additional_authorization_parameters, + on_backchannel_logout: params.on_backchannel_logout, forward_login_hint: params.forward_login_hint, }) } @@ -525,10 +545,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { additional_parameters, forward_login_hint, ui_order, + on_backchannel_logout, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17, $18, $19, $20, - $21, $22, $23, $24) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -554,7 +575,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { response_mode = EXCLUDED.response_mode, additional_parameters = EXCLUDED.additional_parameters, forward_login_hint = EXCLUDED.forward_login_hint, - ui_order = EXCLUDED.ui_order + ui_order = EXCLUDED.ui_order, + on_backchannel_logout = EXCLUDED.on_backchannel_logout RETURNING created_at "#, Uuid::from(id), @@ -595,6 +617,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { Json(¶ms.additional_authorization_parameters) as _, params.forward_login_hint, params.ui_order, + params.on_backchannel_logout.as_str(), created_at, ) .traced() @@ -626,6 +649,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { response_mode: params.response_mode, additional_authorization_parameters: params.additional_authorization_parameters, forward_login_hint: params.forward_login_hint, + on_backchannel_logout: params.on_backchannel_logout, }) } @@ -843,6 +867,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::ForwardLoginHint, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::OnBackchannelLogout, + )), + ProviderLookupIden::OnBackchannelLogout, + ) .from(UpstreamOAuthProviders::Table) .apply_filter(filter) .generate_pagination( @@ -936,7 +967,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, additional_parameters as "additional_parameters: Json>", - forward_login_hint + forward_login_hint, + on_backchannel_logout FROM upstream_oauth_providers WHERE disabled_at IS NULL ORDER BY ui_order ASC, upstream_oauth_provider_id ASC diff --git a/crates/storage-pg/src/upstream_oauth2/session.rs b/crates/storage-pg/src/upstream_oauth2/session.rs index 594f3be4c..b961c4f8c 100644 --- a/crates/storage-pg/src/upstream_oauth2/session.rs +++ b/crates/storage-pg/src/upstream_oauth2/session.rs @@ -1,22 +1,63 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState, UpstreamOAuthLink, - UpstreamOAuthProvider, + Clock, UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState, + UpstreamOAuthLink, UpstreamOAuthProvider, +}; +use mas_storage::{ + Page, Pagination, + pagination::Node, + upstream_oauth2::{UpstreamOAuthSessionFilter, UpstreamOAuthSessionRepository}, }; -use mas_storage::{Clock, upstream_oauth2::UpstreamOAuthSessionRepository}; use rand::RngCore; +use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def, extension::postgres::PgExpr}; +use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; use uuid::Uuid; -use crate::{DatabaseError, DatabaseInconsistencyError, tracing::ExecuteExt}; +use crate::{ + DatabaseError, DatabaseInconsistencyError, + filter::{Filter, StatementExt}, + iden::UpstreamOAuthAuthorizationSessions, + pagination::QueryBuilderExt, + tracing::ExecuteExt, +}; + +impl Filter for UpstreamOAuthSessionFilter<'_> { + fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { + sea_query::Condition::all() + .add_option(self.provider().map(|provider| { + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthProviderId, + )) + .eq(Uuid::from(provider.id)) + })) + .add_option(self.sub_claim().map(|sub| { + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::IdTokenClaims, + )) + .cast_json_field("sub") + .eq(sub) + })) + .add_option(self.sid_claim().map(|sid| { + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::IdTokenClaims, + )) + .cast_json_field("sid") + .eq(sid) + })) + } +} /// An implementation of [`UpstreamOAuthSessionRepository`] for a PostgreSQL /// connection @@ -32,6 +73,8 @@ impl<'c> PgUpstreamOAuthSessionRepository<'c> { } } +#[derive(sqlx::FromRow)] +#[enum_def] struct SessionLookup { upstream_oauth_authorization_session_id: Uuid, upstream_oauth_provider_id: Uuid, @@ -40,6 +83,7 @@ struct SessionLookup { code_challenge_verifier: Option, nonce: Option, id_token: Option, + id_token_claims: Option, userinfo: Option, created_at: DateTime, completed_at: Option>, @@ -48,6 +92,12 @@ struct SessionLookup { unlinked_at: Option>, } +impl Node for SessionLookup { + fn cursor(&self) -> Ulid { + self.upstream_oauth_authorization_session_id.into() + } +} + impl TryFrom for UpstreamOAuthAuthorizationSession { type Error = DatabaseInconsistencyError; @@ -56,18 +106,20 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { let state = match ( value.upstream_oauth_link_id, value.id_token, + value.id_token_claims, value.extra_callback_parameters, value.userinfo, value.completed_at, value.consumed_at, value.unlinked_at, ) { - (None, None, None, None, None, None, None) => { + (None, None, None, None, None, None, None, None) => { UpstreamOAuthAuthorizationSessionState::Pending } ( Some(link_id), id_token, + id_token_claims, extra_callback_parameters, userinfo, Some(completed_at), @@ -77,12 +129,14 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { completed_at, link_id: link_id.into(), id_token, + id_token_claims, extra_callback_parameters, userinfo, }, ( Some(link_id), id_token, + id_token_claims, extra_callback_parameters, userinfo, Some(completed_at), @@ -92,18 +146,27 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { completed_at, link_id: link_id.into(), id_token, + id_token_claims, extra_callback_parameters, userinfo, consumed_at, }, - (_, id_token, _, _, Some(completed_at), consumed_at, Some(unlinked_at)) => { - UpstreamOAuthAuthorizationSessionState::Unlinked { - completed_at, - id_token, - consumed_at, - unlinked_at, - } - } + ( + _, + id_token, + id_token_claims, + _, + _, + Some(completed_at), + consumed_at, + Some(unlinked_at), + ) => UpstreamOAuthAuthorizationSessionState::Unlinked { + completed_at, + id_token, + id_token_claims, + consumed_at, + unlinked_at, + }, _ => { return Err(DatabaseInconsistencyError::on( "upstream_oauth_authorization_sessions", @@ -152,6 +215,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { code_challenge_verifier, nonce, id_token, + id_token_claims, extra_callback_parameters, userinfo, created_at, @@ -253,6 +317,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result { @@ -261,16 +326,18 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { sqlx::query!( r#" UPDATE upstream_oauth_authorization_sessions - SET upstream_oauth_link_id = $1, - completed_at = $2, - id_token = $3, - extra_callback_parameters = $4, - userinfo = $5 - WHERE upstream_oauth_authorization_session_id = $6 + SET upstream_oauth_link_id = $1 + , completed_at = $2 + , id_token = $3 + , id_token_claims = $4 + , extra_callback_parameters = $5 + , userinfo = $6 + WHERE upstream_oauth_authorization_session_id = $7 "#, Uuid::from(upstream_oauth_link.id), completed_at, id_token, + id_token_claims, extra_callback_parameters, userinfo, Uuid::from(upstream_oauth_authorization_session.id), @@ -284,6 +351,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { completed_at, upstream_oauth_link, id_token, + id_token_claims, extra_callback_parameters, userinfo, ) @@ -327,4 +395,173 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { Ok(upstream_oauth_authorization_session) } + + #[tracing::instrument( + name = "db.upstream_oauth_authorization_session.list", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn list( + &mut self, + filter: UpstreamOAuthSessionFilter<'_>, + pagination: Pagination, + ) -> Result, Self::Error> { + let (sql, arguments) = Query::select() + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthAuthorizationSessionId, + )), + SessionLookupIden::UpstreamOauthAuthorizationSessionId, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthProviderId, + )), + SessionLookupIden::UpstreamOauthProviderId, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthLinkId, + )), + SessionLookupIden::UpstreamOauthLinkId, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::State, + )), + SessionLookupIden::State, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::CodeChallengeVerifier, + )), + SessionLookupIden::CodeChallengeVerifier, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::Nonce, + )), + SessionLookupIden::Nonce, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::IdToken, + )), + SessionLookupIden::IdToken, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::IdTokenClaims, + )), + SessionLookupIden::IdTokenClaims, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::ExtraCallbackParameters, + )), + SessionLookupIden::ExtraCallbackParameters, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::Userinfo, + )), + SessionLookupIden::Userinfo, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::CreatedAt, + )), + SessionLookupIden::CreatedAt, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::CompletedAt, + )), + SessionLookupIden::CompletedAt, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::ConsumedAt, + )), + SessionLookupIden::ConsumedAt, + ) + .expr_as( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UnlinkedAt, + )), + SessionLookupIden::UnlinkedAt, + ) + .from(UpstreamOAuthAuthorizationSessions::Table) + .apply_filter(filter) + .generate_pagination( + ( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthAuthorizationSessionId, + ), + pagination, + ) + .build_sqlx(PostgresQueryBuilder); + + let edges: Vec = sqlx::query_as_with(&sql, arguments) + .traced() + .fetch_all(&mut *self.conn) + .await?; + + let page = pagination + .process(edges) + .try_map(UpstreamOAuthAuthorizationSession::try_from)?; + + Ok(page) + } + + #[tracing::instrument( + name = "db.upstream_oauth_authorization_session.count", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn count( + &mut self, + filter: UpstreamOAuthSessionFilter<'_>, + ) -> Result { + let (sql, arguments) = Query::select() + .expr( + Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthAuthorizationSessionId, + )) + .count(), + ) + .from(UpstreamOAuthAuthorizationSessions::Table) + .apply_filter(filter) + .build_sqlx(PostgresQueryBuilder); + + let count: i64 = sqlx::query_scalar_with(&sql, arguments) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count + .try_into() + .map_err(DatabaseError::to_invalid_operation) + } } diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index ad8afd6a8..0f998e55f 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -1,21 +1,22 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + BrowserSession, Clock, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRegistration, }; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, + pagination::Node, user::{UserEmailFilter, UserEmailRepository}, }; use rand::RngCore; -use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; +use sea_query::{Expr, Func, PostgresQueryBuilder, Query, SimpleExpr, enum_def}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; @@ -51,6 +52,12 @@ struct UserEmailLookup { created_at: DateTime, } +impl Node for UserEmailLookup { + fn cursor(&self) -> Ulid { + self.user_email_id.into() + } +} + impl From for UserEmail { fn from(e: UserEmailLookup) -> UserEmail { UserEmail { @@ -110,10 +117,13 @@ impl Filter for UserEmailFilter<'_> { .add_option(self.user().map(|user| { Expr::col((UserEmails::Table, UserEmails::UserId)).eq(Uuid::from(user.id)) })) - .add_option( - self.email() - .map(|email| Expr::col((UserEmails::Table, UserEmails::Email)).eq(email)), - ) + .add_option(self.email().map(|email| { + SimpleExpr::from(Func::lower(Expr::col(( + UserEmails::Table, + UserEmails::Email, + )))) + .eq(Func::lower(email)) + })) } } @@ -175,7 +185,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , created_at FROM user_emails - WHERE user_id = $1 AND email = $2 + WHERE user_id = $1 AND LOWER(email) = LOWER($2) "#, Uuid::from(user.id), email, @@ -209,7 +219,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , email , created_at FROM user_emails - WHERE email = $1 + WHERE LOWER(email) = LOWER($1) "#, email, ) diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 8e755188d..bbf02567e 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -1,20 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing the PostgreSQL implementation of the user-related //! repositories use async_trait::async_trait; -use mas_data_model::User; -use mas_storage::{ - Clock, - user::{UserFilter, UserRepository}, -}; +use mas_data_model::{Clock, User}; +use mas_storage::user::{UserFilter, UserRepository}; use rand::RngCore; -use sea_query::{Expr, PostgresQueryBuilder, Query}; +use sea_query::{Expr, PostgresQueryBuilder, Query, extension::postgres::PgExpr as _}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; @@ -64,7 +61,9 @@ mod priv_ { #![allow(missing_docs)] use chrono::{DateTime, Utc}; + use mas_storage::pagination::Node; use sea_query::enum_def; + use ulid::Ulid; use uuid::Uuid; #[derive(Debug, Clone, sqlx::FromRow)] @@ -76,6 +75,13 @@ mod priv_ { pub(super) locked_at: Option>, pub(super) deactivated_at: Option>, pub(super) can_request_admin: bool, + pub(super) is_guest: bool, + } + + impl Node for UserLookup { + fn cursor(&self) -> Ulid { + self.user_id.into() + } } } @@ -92,6 +98,7 @@ impl From for User { locked_at: value.locked_at, deactivated_at: value.deactivated_at, can_request_admin: value.can_request_admin, + is_guest: value.is_guest, } } } @@ -117,6 +124,13 @@ impl Filter for UserFilter<'_> { .add_option(self.can_request_admin().map(|can_request_admin| { Expr::col((Users::Table, Users::CanRequestAdmin)).eq(can_request_admin) })) + .add_option( + self.is_guest() + .map(|is_guest| Expr::col((Users::Table, Users::IsGuest)).eq(is_guest)), + ) + .add_option(self.search().map(|search| { + Expr::col((Users::Table, Users::Username)).ilike(format!("%{search}%")) + })) } } @@ -143,6 +157,7 @@ impl UserRepository for PgUserRepository<'_> { , locked_at , deactivated_at , can_request_admin + , is_guest FROM users WHERE user_id = $1 "#, @@ -179,6 +194,7 @@ impl UserRepository for PgUserRepository<'_> { , locked_at , deactivated_at , can_request_admin + , is_guest FROM users WHERE LOWER(username) = LOWER($1) "#, @@ -252,6 +268,7 @@ impl UserRepository for PgUserRepository<'_> { locked_at: None, deactivated_at: None, can_request_admin: false, + is_guest: false, }) } @@ -379,7 +396,40 @@ impl UserRepository for PgUserRepository<'_> { DatabaseError::ensure_affected_rows(&res, 1)?; - user.deactivated_at = Some(user.created_at); + user.deactivated_at = Some(deactivated_at); + + Ok(user) + } + + #[tracing::instrument( + name = "db.user.reactivate", + skip_all, + fields( + db.query.text, + %user.id, + ), + err, + )] + async fn reactivate(&mut self, mut user: User) -> Result { + if user.deactivated_at.is_none() { + return Ok(user); + } + + let res = sqlx::query!( + r#" + UPDATE users + SET deactivated_at = NULL + WHERE user_id = $1 + "#, + Uuid::from(user.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user.deactivated_at = None; Ok(user) } @@ -457,6 +507,10 @@ impl UserRepository for PgUserRepository<'_> { Expr::col((Users::Table, Users::CanRequestAdmin)), UserLookupIden::CanRequestAdmin, ) + .expr_as( + Expr::col((Users::Table, Users::IsGuest)), + UserLookupIden::IsGuest, + ) .from(Users::Table) .apply_filter(filter) .generate_pagination((Users::Table, Users::UserId), pagination) diff --git a/crates/storage-pg/src/user/password.rs b/crates/storage-pg/src/user/password.rs index 6a1b19f0a..ca6161270 100644 --- a/crates/storage-pg/src/user/password.rs +++ b/crates/storage-pg/src/user/password.rs @@ -1,13 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{Password, User}; -use mas_storage::{Clock, user::UserPasswordRepository}; +use mas_data_model::{Clock, Password, User}; +use mas_storage::user::UserPasswordRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/user/recovery.rs b/crates/storage-pg/src/user/recovery.rs index bc108b52a..30ab64bcb 100644 --- a/crates/storage-pg/src/user/recovery.rs +++ b/crates/storage-pg/src/user/recovery.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use mas_data_model::{UserEmail, UserRecoverySession, UserRecoveryTicket}; -use mas_storage::{Clock, user::UserRecoveryRepository}; +use mas_data_model::{Clock, UserEmail, UserRecoverySession, UserRecoveryTicket}; +use mas_storage::user::UserRecoveryRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/user/registration.rs b/crates/storage-pg/src/user/registration.rs index 7f123b361..fc62c1cd8 100644 --- a/crates/storage-pg/src/user/registration.rs +++ b/crates/storage-pg/src/user/registration.rs @@ -1,16 +1,17 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - UserEmailAuthentication, UserRegistration, UserRegistrationPassword, UserRegistrationToken, + Clock, UserEmailAuthentication, UserRegistration, UserRegistrationPassword, + UserRegistrationToken, }; -use mas_storage::{Clock, user::UserRegistrationRepository}; +use mas_storage::user::UserRegistrationRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; @@ -432,8 +433,7 @@ impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { mod tests { use std::net::{IpAddr, Ipv4Addr}; - use mas_data_model::UserRegistrationPassword; - use mas_storage::{Clock, clock::MockClock}; + use mas_data_model::{Clock, UserRegistrationPassword, clock::MockClock}; use rand::SeedableRng; use rand_chacha::ChaChaRng; use sqlx::PgPool; @@ -524,7 +524,7 @@ mod tests { &mut rng, &clock, "alice".to_owned(), - Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), + Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), Some("Mozilla/5.0".to_owned()), Some(serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"})), ) @@ -534,7 +534,7 @@ mod tests { assert_eq!(registration.user_agent, Some("Mozilla/5.0".to_owned())); assert_eq!( registration.ip_address, - Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))) + Some(IpAddr::V4(Ipv4Addr::LOCALHOST)) ); assert_eq!( registration.post_auth_action, diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs index f7a9ab54e..5c9231aa1 100644 --- a/crates/storage-pg/src/user/registration_token.rs +++ b/crates/storage-pg/src/user/registration_token.rs @@ -1,13 +1,14 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::UserRegistrationToken; +use mas_data_model::{Clock, UserRegistrationToken}; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, + pagination::Node, user::{UserRegistrationTokenFilter, UserRegistrationTokenRepository}, }; use rand::RngCore; @@ -53,8 +54,13 @@ struct UserRegistrationTokenLookup { revoked_at: Option>, } +impl Node for UserRegistrationTokenLookup { + fn cursor(&self) -> Ulid { + self.user_registration_token_id.into() + } +} + impl Filter for UserRegistrationTokenFilter { - #[expect(clippy::too_many_lines)] fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { sea_query::Condition::all() .add_option(self.has_been_used().map(|has_been_used| { @@ -231,7 +237,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { filter: UserRegistrationTokenFilter, pagination: Pagination, ) -> Result, Self::Error> { - let (sql, values) = Query::select() + let (sql, arguments) = Query::select() .expr_as( Expr::col(( UserRegistrationTokens::Table, @@ -296,15 +302,14 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { ) .build_sqlx(PostgresQueryBuilder); - let tokens = sqlx::query_as_with::<_, UserRegistrationTokenLookup, _>(&sql, values) + let edges: Vec = sqlx::query_as_with(&sql, arguments) .traced() .fetch_all(&mut *self.conn) - .await? - .into_iter() - .map(TryInto::try_into) - .collect::, _>>()?; + .await?; - let page = pagination.process(tokens); + let page = pagination + .process(edges) + .try_map(UserRegistrationToken::try_from)?; Ok(page) } @@ -430,7 +435,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { async fn add( &mut self, rng: &mut (dyn RngCore + Send), - clock: &dyn mas_storage::Clock, + clock: &dyn mas_data_model::Clock, token: String, usage_limit: Option, expires_at: Option>, @@ -656,9 +661,8 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { #[cfg(test)] mod tests { use chrono::Duration; - use mas_storage::{ - Clock as _, Pagination, clock::MockClock, user::UserRegistrationTokenFilter, - }; + use mas_data_model::{Clock as _, clock::MockClock}; + use mas_storage::{Pagination, user::UserRegistrationTokenFilter}; use rand::SeedableRng; use rand_chacha::ChaChaRng; use sqlx::PgPool; @@ -707,7 +711,7 @@ mod tests { .await .unwrap(); - assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id)); + assert!(page.edges.iter().any(|t| t.node.id == unrevoked_token.id)); } #[sqlx::test(migrator = "crate::MIGRATOR")] @@ -869,7 +873,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token2.id); + assert_eq!(page.edges[0].node.id, token2.id); // Test unused filter let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false); @@ -888,7 +892,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token3.id); + assert_eq!(page.edges[0].node.id, token3.id); let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false); let page = repo @@ -906,7 +910,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token4.id); + assert_eq!(page.edges[0].node.id, token4.id); let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false); let page = repo @@ -943,7 +947,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token4.id); + assert_eq!(page.edges[0].node.id, token4.id); // Test pagination let page = repo diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index 3bea6781c..8644d7666 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -1,23 +1,24 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - Authentication, AuthenticationMethod, BrowserSession, Password, + Authentication, AuthenticationMethod, BrowserSession, Clock, Password, UpstreamOAuthAuthorizationSession, User, }; use mas_storage::{ - Clock, Page, Pagination, + Page, Pagination, + pagination::Node, user::{BrowserSessionFilter, BrowserSessionRepository}, }; use rand::RngCore; -use sea_query::{Expr, PostgresQueryBuilder}; +use sea_query::{Expr, PostgresQueryBuilder, Query}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; @@ -26,7 +27,7 @@ use uuid::Uuid; use crate::{ DatabaseError, DatabaseInconsistencyError, filter::StatementExt, - iden::{UserSessions, Users}, + iden::{UpstreamOAuthAuthorizationSessions, UserSessionAuthentications, UserSessions, Users}, pagination::QueryBuilderExt, tracing::ExecuteExt, }; @@ -61,6 +62,13 @@ struct SessionLookup { user_locked_at: Option>, user_deactivated_at: Option>, user_can_request_admin: bool, + user_is_guest: bool, +} + +impl Node for SessionLookup { + fn cursor(&self) -> Ulid { + self.user_id.into() + } } impl TryFrom for BrowserSession { @@ -76,6 +84,7 @@ impl TryFrom for BrowserSession { locked_at: value.user_locked_at, deactivated_at: value.user_deactivated_at, can_request_admin: value.user_can_request_admin, + is_guest: value.user_is_guest, }; Ok(BrowserSession { @@ -145,6 +154,30 @@ impl crate::filter::Filter for BrowserSessionFilter<'_> { .add_option(self.last_active_before().map(|last_active_before| { Expr::col((UserSessions::Table, UserSessions::LastActiveAt)).lt(last_active_before) })) + .add_option(self.authenticated_by_upstream_sessions().map(|filter| { + // For filtering by upstream sessions, we need to hop over the + // `user_session_authentications` table + let join_expr = Expr::col(( + UserSessionAuthentications::Table, + UserSessionAuthentications::UpstreamOAuthAuthorizationSessionId, + )) + .eq(Expr::col(( + UpstreamOAuthAuthorizationSessions::Table, + UpstreamOAuthAuthorizationSessions::UpstreamOAuthAuthorizationSessionId, + ))); + + Expr::col((UserSessions::Table, UserSessions::UserSessionId)).in_subquery( + Query::select() + .expr(Expr::col(( + UserSessionAuthentications::Table, + UserSessionAuthentications::UserSessionId, + ))) + .from(UserSessionAuthentications::Table) + .inner_join(UpstreamOAuthAuthorizationSessions::Table, join_expr) + .apply_filter(filter) + .take(), + ) + })) } } @@ -177,6 +210,7 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { , u.locked_at AS "user_locked_at" , u.deactivated_at AS "user_deactivated_at" , u.can_request_admin AS "user_can_request_admin" + , u.is_guest AS "user_is_guest" FROM user_sessions s INNER JOIN users u USING (user_id) @@ -367,6 +401,10 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { Expr::col((Users::Table, Users::CanRequestAdmin)), SessionLookupIden::UserCanRequestAdmin, ) + .expr_as( + Expr::col((Users::Table, Users::IsGuest)), + SessionLookupIden::UserIsGuest, + ) .from(UserSessions::Table) .inner_join( Users::Table, diff --git a/crates/storage-pg/src/user/terms.rs b/crates/storage-pg/src/user/terms.rs index 9efa1f008..df83d9375 100644 --- a/crates/storage-pg/src/user/terms.rs +++ b/crates/storage-pg/src/user/terms.rs @@ -1,12 +1,12 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::User; -use mas_storage::{Clock, user::UserTermsRepository}; +use mas_data_model::{Clock, User}; +use mas_storage::user::UserTermsRepository; use rand::RngCore; use sqlx::PgConnection; use ulid::Ulid; diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index c37c7ff8e..98489d68d 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -1,18 +1,21 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::Duration; +use mas_data_model::{Clock, clock::MockClock}; +use mas_iana::jose::JsonWebSignatureAlg; use mas_storage::{ - Clock, Pagination, RepositoryAccess, - clock::MockClock, + Pagination, RepositoryAccess, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthSessionFilter}, user::{ BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository, UserFilter, UserPasswordRepository, UserRepository, }, }; +use oauth2_types::scope::{OPENID, Scope}; use rand::SeedableRng; use rand_chacha::ChaChaRng; use sqlx::PgPool; @@ -171,10 +174,23 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(locked).await.unwrap(), 0); assert_eq!(repo.user().count(deactivated).await.unwrap(), 1); + // Test the search filter + assert_eq!( + repo.user() + .count(all.matching_search("alice")) + .await + .unwrap(), + 0 + ); + assert_eq!( + repo.user().count(all.matching_search("JO")).await.unwrap(), + 1 + ); + // Check the list method let list = repo.user().list(all, Pagination::first(10)).await.unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges[0].node.id, user.id); let list = repo .user() @@ -189,7 +205,7 @@ async fn test_user_repo(pool: PgPool) { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges[0].node.id, user.id); let list = repo .user() @@ -211,7 +227,7 @@ async fn test_user_repo(pool: PgPool) { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges[0].node.id, user.id); repo.save().await.unwrap(); } @@ -265,6 +281,10 @@ async fn test_user_repo_find_by_username(pool: PgPool) { async fn test_user_email_repo(pool: PgPool) { const USERNAME: &str = "john"; const EMAIL: &str = "john@example.com"; + // This is what is stored in the database, making sure that: + // 1. we don't normalize the email address when storing it + // 2. looking it up is case-incensitive + const UPPERCASE_EMAIL: &str = "JOHN@EXAMPLE.COM"; let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); let mut rng = ChaChaRng::seed_from_u64(42); @@ -292,12 +312,12 @@ async fn test_user_email_repo(pool: PgPool) { let user_email = repo .user_email() - .add(&mut rng, &clock, &user, EMAIL.to_owned()) + .add(&mut rng, &clock, &user, UPPERCASE_EMAIL.to_owned()) .await .unwrap(); assert_eq!(user_email.user_id, user.id); - assert_eq!(user_email.email, EMAIL); + assert_eq!(user_email.email, UPPERCASE_EMAIL); // Check the counts assert_eq!(repo.user_email().count(all).await.unwrap(), 1); @@ -318,7 +338,7 @@ async fn test_user_email_repo(pool: PgPool) { .expect("user email was not found"); assert_eq!(user_email.user_id, user.id); - assert_eq!(user_email.email, EMAIL); + assert_eq!(user_email.email, UPPERCASE_EMAIL); // Listing the user emails should work let emails = repo @@ -328,7 +348,7 @@ async fn test_user_email_repo(pool: PgPool) { .unwrap(); assert!(!emails.has_next_page); assert_eq!(emails.edges.len(), 1); - assert_eq!(emails.edges[0], user_email); + assert_eq!(emails.edges[0].node, user_email); // Listing emails from the email address should work let emails = repo @@ -338,7 +358,7 @@ async fn test_user_email_repo(pool: PgPool) { .unwrap(); assert!(!emails.has_next_page); assert_eq!(emails.edges.len(), 1); - assert_eq!(emails.edges[0], user_email); + assert_eq!(emails.edges[0].node, user_email); // Filtering on another email should not return anything let emails = repo @@ -628,7 +648,7 @@ async fn test_user_session(pool: PgPool) { .unwrap(); assert!(!session_list.has_next_page); assert_eq!(session_list.edges.len(), 1); - assert_eq!(session_list.edges[0], session); + assert_eq!(session_list.edges[0].node, session); let session_lookup = repo .browser_session() @@ -717,6 +737,100 @@ async fn test_user_session(pool: PgPool) { assert_eq!(repo.browser_session().count(all_bob).await.unwrap(), 5); assert_eq!(repo.browser_session().count(active_bob).await.unwrap(), 0); assert_eq!(repo.browser_session().count(finished).await.unwrap(), 11); + + // Checking the 'authenticaated by upstream sessions' filter + // We need a provider + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &clock, + UpstreamOAuthProviderParams { + issuer: None, + human_name: None, + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports: mas_data_model::UpstreamOAuthProviderClaimsImports::default(), + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Disabled, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Disabled, + response_mode: None, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + ui_order: 0, + on_backchannel_logout: + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + }, + ) + .await + .unwrap(); + + // Start a authorization session + let upstream_oauth_session = repo + .upstream_oauth_session() + .add(&mut rng, &clock, &provider, "state".to_owned(), None, None) + .await + .unwrap(); + + // Start a browser session + let session = repo + .browser_session() + .add(&mut rng, &clock, &alice, None) + .await + .unwrap(); + + // Make the session from alice authenticated by this session + repo.browser_session() + .authenticate_with_upstream(&mut rng, &clock, &session, &upstream_oauth_session) + .await + .unwrap(); + + // This will match all authorization sessions, which matches exactly that one + // authorization session + let upstream_oauth_session_filter = UpstreamOAuthSessionFilter::new(); + let filter = BrowserSessionFilter::new() + .authenticated_by_upstream_sessions_only(upstream_oauth_session_filter); + + // Now try to look it up + let page = repo + .browser_session() + .list(filter, Pagination::first(10)) + .await + .unwrap(); + assert_eq!(page.edges.len(), 1); + assert_eq!(page.edges[0].node.id, session.id); + + // Try counting + assert_eq!(repo.browser_session().count(filter).await.unwrap(), 1); + + // Try finishing the session + let affected = repo + .browser_session() + .finish_bulk(&clock, filter) + .await + .unwrap(); + assert_eq!(affected, 1); + + // Lookup the session by its ID + let lookup = repo + .browser_session() + .lookup(session.id) + .await + .unwrap() + .expect("session to be found in the database"); + // It should be finished + assert!(lookup.finished_at.is_some()); } #[sqlx::test(migrator = "crate::MIGRATOR")] diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index f22ef1d6e..07f4330c6 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-storage" version.workspace = true diff --git a/crates/storage/src/app_session.rs b/crates/storage/src/app_session.rs index fd1850d3d..d649ff35e 100644 --- a/crates/storage/src/app_session.rs +++ b/crates/storage/src/app_session.rs @@ -1,16 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repositories to interact with all kinds of sessions use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, CompatSession, Device, Session, User}; +use mas_data_model::{BrowserSession, Clock, CompatSession, Device, Session, User}; -use crate::{Clock, Page, Pagination, repository_impl}; +use crate::{Page, Pagination, repository_impl}; /// The state of a session #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/storage/src/compat/access_token.rs b/crates/storage/src/compat/access_token.rs index 87d244f4d..8dd41ba43 100644 --- a/crates/storage/src/compat/access_token.rs +++ b/crates/storage/src/compat/access_token.rs @@ -1,19 +1,19 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::Duration; -use mas_data_model::{CompatAccessToken, CompatSession}; +use mas_data_model::{Clock, CompatAccessToken, CompatSession}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`CompatAccessTokenRepository`] helps interacting with -/// [`CompatAccessToken`] saved in the storage backend +/// [`CompatAccessToken`] saved in the storage backend #[async_trait] pub trait CompatAccessTokenRepository: Send + Sync { /// The error type returned by the repository diff --git a/crates/storage/src/compat/mod.rs b/crates/storage/src/compat/mod.rs index 3bb11717b..8c518d423 100644 --- a/crates/storage/src/compat/mod.rs +++ b/crates/storage/src/compat/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repositories to interact with entities of the compatibility layer diff --git a/crates/storage/src/compat/refresh_token.rs b/crates/storage/src/compat/refresh_token.rs index ff568ec9f..6ffefbc69 100644 --- a/crates/storage/src/compat/refresh_token.rs +++ b/crates/storage/src/compat/refresh_token.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{CompatAccessToken, CompatRefreshToken, CompatSession}; +use mas_data_model::{Clock, CompatAccessToken, CompatRefreshToken, CompatSession}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`CompatRefreshTokenRepository`] helps interacting with /// [`CompatRefreshToken`] saved in the storage backend diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index e935e986b..d16351c0a 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -1,18 +1,18 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User}; +use mas_data_model::{BrowserSession, Clock, CompatSession, CompatSsoLogin, Device, User}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, Page, Pagination, repository_impl}; +use crate::{Page, Pagination, repository_impl, user::BrowserSessionFilter}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CompatSessionState { @@ -59,6 +59,7 @@ impl CompatSessionType { pub struct CompatSessionFilter<'a> { user: Option<&'a User>, browser_session: Option<&'a BrowserSession>, + browser_session_filter: Option>, state: Option, auth_type: Option, device: Option<&'a Device>, @@ -106,12 +107,28 @@ impl<'a> CompatSessionFilter<'a> { self } + /// Set the browser sessions filter + #[must_use] + pub fn for_browser_sessions( + mut self, + browser_session_filter: BrowserSessionFilter<'a>, + ) -> Self { + self.browser_session_filter = Some(browser_session_filter); + self + } + /// Get the browser session filter #[must_use] pub fn browser_session(&self) -> Option<&'a BrowserSession> { self.browser_session } + /// Get the browser sessions filter + #[must_use] + pub fn browser_session_filter(&self) -> Option> { + self.browser_session_filter + } + /// Only return sessions with a last active time before the given time #[must_use] pub fn with_last_active_before(mut self, last_active_before: DateTime) -> Self { diff --git a/crates/storage/src/compat/sso_login.rs b/crates/storage/src/compat/sso_login.rs index 08e8c5491..69de52948 100644 --- a/crates/storage/src/compat/sso_login.rs +++ b/crates/storage/src/compat/sso_login.rs @@ -1,16 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, User}; +use mas_data_model::{BrowserSession, Clock, CompatSession, CompatSsoLogin, User}; use rand_core::RngCore; use ulid::Ulid; use url::Url; -use crate::{Clock, Pagination, pagination::Page, repository_impl}; +use crate::{Pagination, pagination::Page, repository_impl}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CompatSsoLoginState { diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 07d8bd97c..7a19f05ac 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Interactions with the storage backend //! @@ -15,13 +15,6 @@ //! without caring about the underlying storage backend, and without carrying //! around the generic type parameter. //! -//! This crate also defines a [`Clock`] trait that can be used to abstract the -//! way the current time is retrieved. It has two implementation: -//! [`SystemClock`] that uses the system time and [`MockClock`] which is useful -//! for testing. -//! -//! [`MockClock`]: crate::clock::MockClock -//! //! # Defining a new repository //! //! To define a new repository, you have to: @@ -86,9 +79,9 @@ //! and use that error type //! 2. Lookups return an `Result, Self::Error>`, because 'not found' //! errors are usually cases that are handled differently -//! 3. Operations that need to record the current type use a [`Clock`] -//! parameter. Operations that need to generate new IDs also use a random -//! number generator. +//! 3. Operations that need to record the current type use a +//! [`mas_data_model::Clock`] parameter. Operations that need to generate +//! new IDs also use a random number generator. //! 4. All the methods use an `&mut self`. This is ensures only one operation //! is done at a time on a single repository instance. //! @@ -111,7 +104,6 @@ #![deny(clippy::future_not_send, missing_docs)] #![allow(clippy::module_name_repetitions)] -pub mod clock; pub mod pagination; pub(crate) mod repository; mod utils; @@ -119,17 +111,17 @@ mod utils; pub mod app_session; pub mod compat; pub mod oauth2; +pub mod personal; pub mod policy_data; pub mod queue; pub mod upstream_oauth2; pub mod user; pub use self::{ - clock::{Clock, SystemClock}, pagination::{Page, Pagination}, repository::{ BoxRepository, BoxRepositoryFactory, Repository, RepositoryAccess, RepositoryError, RepositoryFactory, RepositoryTransaction, }, - utils::{BoxClock, BoxRng, MapErr}, + utils::MapErr, }; diff --git a/crates/storage/src/oauth2/access_token.rs b/crates/storage/src/oauth2/access_token.rs index 9a128bbdb..a678bc59e 100644 --- a/crates/storage/src/oauth2/access_token.rs +++ b/crates/storage/src/oauth2/access_token.rs @@ -1,16 +1,16 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::Duration; -use mas_data_model::{AccessToken, Session}; +use mas_data_model::{AccessToken, Clock, Session}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// An [`OAuth2AccessTokenRepository`] helps interacting with [`AccessToken`] /// saved in the storage backend diff --git a/crates/storage/src/oauth2/authorization_grant.rs b/crates/storage/src/oauth2/authorization_grant.rs index cb4802a92..c019f6bd7 100644 --- a/crates/storage/src/oauth2/authorization_grant.rs +++ b/crates/storage/src/oauth2/authorization_grant.rs @@ -1,17 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{AuthorizationCode, AuthorizationGrant, Client, Session}; +use mas_data_model::{AuthorizationCode, AuthorizationGrant, Client, Clock, Session}; use oauth2_types::{requests::ResponseMode, scope::Scope}; use rand_core::RngCore; use ulid::Ulid; use url::Url; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// An [`OAuth2AuthorizationGrantRepository`] helps interacting with /// [`AuthorizationGrant`] saved in the storage backend @@ -38,7 +38,7 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync { /// * `response_mode`: The response mode the client requested /// * `response_type_id_token`: Whether the `id_token` `response_type` was /// requested - /// * `login_hint`: The login_hint the client sent, if set + /// * `login_hint`: The `login_hint` the client sent, if set /// * `locale`: The locale the detected when the user asked for the /// authorization grant /// diff --git a/crates/storage/src/oauth2/client.rs b/crates/storage/src/oauth2/client.rs index 33b92d189..7e376e1c3 100644 --- a/crates/storage/src/oauth2/client.rs +++ b/crates/storage/src/oauth2/client.rs @@ -1,13 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::{BTreeMap, BTreeSet}; use async_trait::async_trait; -use mas_data_model::Client; +use mas_data_model::{Client, Clock}; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use mas_jose::jwk::PublicJsonWebKeySet; use oauth2_types::{oidc::ApplicationType, requests::GrantType}; @@ -15,16 +15,16 @@ use rand_core::RngCore; use ulid::Ulid; use url::Url; -use crate::{Clock, repository_impl}; +use crate::repository_impl; -/// An [`OAuth2ClientRepository`] helps interacting with [`Client`] saved in the +/// An [`OAuth2ClientRepository`] helps interacting with [`Client`] saved in the /// storage backend #[async_trait] pub trait OAuth2ClientRepository: Send + Sync { /// The error type returned by the repository type Error; - /// Lookup an OAuth2 client by its ID + /// Lookup an OAuth client by its ID /// /// Returns `None` if the client does not exist /// @@ -37,7 +37,7 @@ pub trait OAuth2ClientRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; - /// Find an OAuth2 client by its client ID + /// Find an OAuth client by its client ID async fn find_by_client_id(&mut self, client_id: &str) -> Result, Self::Error> { let Ok(id) = client_id.parse() else { return Ok(None); @@ -45,7 +45,7 @@ pub trait OAuth2ClientRepository: Send + Sync { self.lookup(id).await } - /// Find an OAuth2 client by its metadata digest + /// Find an OAuth client by its metadata digest /// /// Returns `None` if the client does not exist /// @@ -62,7 +62,7 @@ pub trait OAuth2ClientRepository: Send + Sync { digest: &str, ) -> Result, Self::Error>; - /// Load a batch of OAuth2 clients by their IDs + /// Load a batch of OAuth clients by their IDs /// /// Returns a map of client IDs to clients. If a client does not exist, it /// is not present in the map. @@ -79,7 +79,7 @@ pub trait OAuth2ClientRepository: Send + Sync { ids: BTreeSet, ) -> Result, Self::Error>; - /// Add a new OAuth2 client + /// Add a new OAuth client /// /// Returns the client that was added /// diff --git a/crates/storage/src/oauth2/device_code_grant.rs b/crates/storage/src/oauth2/device_code_grant.rs index 762e854cc..b1b1bd14e 100644 --- a/crates/storage/src/oauth2/device_code_grant.rs +++ b/crates/storage/src/oauth2/device_code_grant.rs @@ -1,19 +1,19 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::Duration; -use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session}; +use mas_data_model::{BrowserSession, Client, Clock, DeviceCodeGrant, Session}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// Parameters used to create a new [`DeviceCodeGrant`] pub struct OAuth2DeviceCodeGrantParams<'a> { diff --git a/crates/storage/src/oauth2/mod.rs b/crates/storage/src/oauth2/mod.rs index 8f6b86056..a2d172567 100644 --- a/crates/storage/src/oauth2/mod.rs +++ b/crates/storage/src/oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repositories to interact with entities related to the OAuth 2.0 protocol diff --git a/crates/storage/src/oauth2/refresh_token.rs b/crates/storage/src/oauth2/refresh_token.rs index 7f3928906..b81420a08 100644 --- a/crates/storage/src/oauth2/refresh_token.rs +++ b/crates/storage/src/oauth2/refresh_token.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{AccessToken, RefreshToken, Session}; +use mas_data_model::{AccessToken, Clock, RefreshToken, Session}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// An [`OAuth2RefreshTokenRepository`] helps interacting with [`RefreshToken`] /// saved in the storage backend diff --git a/crates/storage/src/oauth2/session.rs b/crates/storage/src/oauth2/session.rs index 07f91a2b0..30ac1abe1 100644 --- a/crates/storage/src/oauth2/session.rs +++ b/crates/storage/src/oauth2/session.rs @@ -1,19 +1,19 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Client, Device, Session, User}; +use mas_data_model::{BrowserSession, Client, Clock, Device, Session, User}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, Pagination, pagination::Page, repository_impl}; +use crate::{Pagination, pagination::Page, repository_impl, user::BrowserSessionFilter}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OAuth2SessionState { @@ -49,6 +49,7 @@ pub struct OAuth2SessionFilter<'a> { user: Option<&'a User>, any_user: Option, browser_session: Option<&'a BrowserSession>, + browser_session_filter: Option>, device: Option<&'a Device>, client: Option<&'a Client>, client_kind: Option, @@ -109,6 +110,16 @@ impl<'a> OAuth2SessionFilter<'a> { self } + /// List sessions started by a set of browser sessions + #[must_use] + pub fn for_browser_sessions( + mut self, + browser_session_filter: BrowserSessionFilter<'a>, + ) -> Self { + self.browser_session_filter = Some(browser_session_filter); + self + } + /// Get the browser session filter /// /// Returns [`None`] if no browser session filter was set @@ -117,6 +128,14 @@ impl<'a> OAuth2SessionFilter<'a> { self.browser_session } + /// Get the browser sessions filter + /// + /// Returns [`None`] if no browser session filter was set + #[must_use] + pub fn browser_session_filter(&self) -> Option> { + self.browser_session_filter + } + /// List sessions for a specific client #[must_use] pub fn for_client(mut self, client: &'a Client) -> Self { diff --git a/crates/storage/src/pagination.rs b/crates/storage/src/pagination.rs index 318caff70..ad632cb10 100644 --- a/crates/storage/src/pagination.rs +++ b/crates/storage/src/pagination.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Utilities to manage paginated queries. @@ -16,12 +16,12 @@ pub struct InvalidPagination; /// Pagination parameters #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Pagination { +pub struct Pagination { /// The cursor to start from - pub before: Option, + pub before: Option, /// The cursor to end at - pub after: Option, + pub after: Option, /// The maximum number of items to return pub count: usize, @@ -40,16 +40,22 @@ pub enum PaginationDirection { Backward, } -impl Pagination { +/// A node in a page, with a cursor +pub trait Node { + /// The cursor of that particular node + fn cursor(&self) -> C; +} + +impl Pagination { /// Creates a new [`Pagination`] from user-provided parameters. /// /// # Errors /// /// Either `first` or `last` must be provided, else this function will /// return an [`InvalidPagination`] error. - pub const fn try_new( - before: Option, - after: Option, + pub fn try_new( + before: Option, + after: Option, first: Option, last: Option, ) -> Result { @@ -91,49 +97,57 @@ impl Pagination { /// Get items before the given cursor #[must_use] - pub const fn before(mut self, id: Ulid) -> Self { - self.before = Some(id); + pub fn before(mut self, cursor: C) -> Self { + self.before = Some(cursor); self } /// Clear the before cursor #[must_use] - pub const fn clear_before(mut self) -> Self { + pub fn clear_before(mut self) -> Self { self.before = None; self } /// Get items after the given cursor #[must_use] - pub const fn after(mut self, id: Ulid) -> Self { - self.after = Some(id); + pub fn after(mut self, cursor: C) -> Self { + self.after = Some(cursor); self } /// Clear the after cursor #[must_use] - pub const fn clear_after(mut self) -> Self { + pub fn clear_after(mut self) -> Self { self.after = None; self } /// Process a page returned by a paginated query #[must_use] - pub fn process(&self, mut edges: Vec) -> Page { - let is_full = edges.len() == (self.count + 1); + pub fn process>(&self, mut nodes: Vec) -> Page { + let is_full = nodes.len() == (self.count + 1); if is_full { - edges.pop(); + nodes.pop(); } let (has_previous_page, has_next_page) = match self.direction { PaginationDirection::Forward => (false, is_full), PaginationDirection::Backward => { // 6. If the last argument is provided, I reverse the order of the results - edges.reverse(); + nodes.reverse(); (is_full, false) } }; + let edges = nodes + .into_iter() + .map(|node| Edge { + cursor: node.cursor(), + node, + }) + .collect(); + Page { has_next_page, has_previous_page, @@ -142,9 +156,18 @@ impl Pagination { } } +/// An edge in a paginated result +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + /// The cursor of the edge + pub cursor: C, + /// The node of the edge + pub node: T, +} + /// A page of results returned by a paginated query #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Page { +pub struct Page { /// When paginating forwards, this is true if there are more items after pub has_next_page: bool, @@ -152,21 +175,28 @@ pub struct Page { pub has_previous_page: bool, /// The items in the page - pub edges: Vec, + pub edges: Vec>, } -impl Page { +impl Page { /// Map the items in this page with the given function /// /// # Parameters /// /// * `f`: The function to map the items with #[must_use] - pub fn map(self, f: F) -> Page + pub fn map(self, mut f: F) -> Page where F: FnMut(T) -> T2, { - let edges = self.edges.into_iter().map(f).collect(); + let edges = self + .edges + .into_iter() + .map(|edge| Edge { + cursor: edge.cursor, + node: f(edge.node), + }) + .collect(); Page { has_next_page: self.has_next_page, has_previous_page: self.has_previous_page, @@ -183,11 +213,21 @@ impl Page { /// # Errors /// /// Returns the first error encountered while mapping the items - pub fn try_map(self, f: F) -> Result, E> + pub fn try_map(self, mut f: F) -> Result, E> where F: FnMut(T) -> Result, { - let edges: Result, E> = self.edges.into_iter().map(f).collect(); + let edges: Result>, E> = self + .edges + .into_iter() + .map(|edge| { + Ok(Edge { + cursor: edge.cursor, + node: f(edge.node)?, + }) + }) + .collect(); + Ok(Page { has_next_page: self.has_next_page, has_previous_page: self.has_previous_page, diff --git a/crates/storage/src/personal/access_token.rs b/crates/storage/src/personal/access_token.rs new file mode 100644 index 000000000..363a3199f --- /dev/null +++ b/crates/storage/src/personal/access_token.rs @@ -0,0 +1,140 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::Duration; +use mas_data_model::{ + Clock, + personal::{PersonalAccessToken, session::PersonalSession}, +}; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::repository_impl; + +/// An [`PersonalAccessTokenRepository`] helps interacting with +/// [`PersonalAccessToken`] saved in the storage backend +#[async_trait] +pub trait PersonalAccessTokenRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup an access token by its ID + /// + /// Returns the access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `id`: The ID of the access token to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Find an access token by its token + /// + /// Returns the access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `access_token`: The token of the access token to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error>; + + /// Find the active access token belonging to a given session. + /// + /// Returns the active access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `session`: The session to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_active_for_session( + &mut self, + session: &PersonalSession, + ) -> Result, Self::Error>; + + /// Add a new access token to the database + /// + /// Returns the newly created access token + /// + /// # Parameters + /// + /// * `rng`: A random number generator + /// * `clock`: The clock used to generate timestamps + /// * `session`: The session the access token is associated with + /// * `access_token`: The access token to add + /// * `expires_after`: The duration after which the access token expires. If + /// [`None`] the access token never expires + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: &str, + expires_after: Option, + ) -> Result; + + /// Revoke an access token + /// + /// Returns the revoked access token + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `access_token`: The access token to revoke + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + access_token: PersonalAccessToken, + ) -> Result; +} + +repository_impl!(PersonalAccessTokenRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error>; + + async fn find_active_for_session( + &mut self, + session: &PersonalSession, + ) -> Result, Self::Error>; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: &str, + expires_after: Option, + ) -> Result; + + async fn revoke( + &mut self, + clock: &dyn Clock, + access_token: PersonalAccessToken, + ) -> Result; +); diff --git a/crates/storage/src/personal/mod.rs b/crates/storage/src/personal/mod.rs new file mode 100644 index 000000000..3a9dfcd65 --- /dev/null +++ b/crates/storage/src/personal/mod.rs @@ -0,0 +1,16 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Repositories to deal with Personal Sessions and Personal Access Tokens +//! (PATs), which are sessions/access tokens created manually by users for use +//! in scripts, bots and similar applications. + +mod access_token; +mod session; + +pub use self::{ + access_token::PersonalAccessTokenRepository, + session::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState}, +}; diff --git a/crates/storage/src/personal/session.rs b/crates/storage/src/personal/session.rs new file mode 100644 index 000000000..921c6df39 --- /dev/null +++ b/crates/storage/src/personal/session.rs @@ -0,0 +1,398 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Client, Clock, Device, User, + personal::{ + PersonalAccessToken, + session::{PersonalSession, PersonalSessionOwner}, + }, +}; +use oauth2_types::scope::Scope; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::{Page, Pagination, repository_impl}; + +/// A [`PersonalSessionRepository`] helps interacting with +/// [`PersonalSession`] saved in the storage backend +#[async_trait] +pub trait PersonalSessionRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup a Personal session by its ID + /// + /// Returns the Personal session if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `id`: The ID of the Personal session to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Start a new Personal session + /// + /// Returns the newly created Personal session + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `owner_user`: The user that will own the personal session + /// * `actor_user`: The user that will be represented by the personal + /// session + /// * `device`: The device ID of this session + /// * `human_name`: The human-readable name of the session provided by the + /// client or the user + /// * `scope`: The [`Scope`] of the [`PersonalSession`] + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner: PersonalSessionOwner, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result; + + /// End a Personal session + /// + /// Returns the ended Personal session + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `Personal_session`: The Personal session to end + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + personal_session: PersonalSession, + ) -> Result; + + /// Revoke all the [`PersonalSession`]s matching the given filter. + /// + /// Returns the number of sessions affected + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `filter`: The filter to apply + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke_bulk( + &mut self, + clock: &dyn Clock, + filter: PersonalSessionFilter<'_>, + ) -> Result; + + /// List [`PersonalSession`]s matching the given filter and pagination + /// parameters + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// * `pagination`: The pagination parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn list( + &mut self, + filter: PersonalSessionFilter<'_>, + pagination: Pagination, + ) -> Result)>, Self::Error>; + + /// Count [`PersonalSession`]s matching the given filter + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result; + + /// Record a batch of [`PersonalSession`] activity + /// + /// # Parameters + /// + /// * `activity`: A list of tuples containing the session ID, the last + /// activity timestamp and the IP address of the client + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn record_batch_activity( + &mut self, + activity: Vec<(Ulid, DateTime, Option)>, + ) -> Result<(), Self::Error>; +} + +repository_impl!(PersonalSessionRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner: PersonalSessionOwner, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result; + + async fn revoke( + &mut self, + clock: &dyn Clock, + personal_session: PersonalSession, + ) -> Result; + + async fn revoke_bulk( + &mut self, + clock: &dyn Clock, + filter: PersonalSessionFilter<'_>, + ) -> Result; + + async fn list( + &mut self, + filter: PersonalSessionFilter<'_>, + pagination: Pagination, + ) -> Result)>, Self::Error>; + + async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result; + + async fn record_batch_activity( + &mut self, + activity: Vec<(Ulid, DateTime, Option)>, + ) -> Result<(), Self::Error>; +); + +/// Filter parameters for listing personal sessions alongside personal access +/// tokens +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct PersonalSessionFilter<'a> { + owner_user: Option<&'a User>, + owner_oauth2_client: Option<&'a Client>, + actor_user: Option<&'a User>, + device: Option<&'a Device>, + state: Option, + scope: Option<&'a Scope>, + last_active_before: Option>, + last_active_after: Option>, + expires_before: Option>, + expires_after: Option>, + expires: Option, +} + +/// Filter for what state a personal session is in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PersonalSessionState { + /// The personal session is active, which means it either + /// has active access tokens or can have new access tokens generated. + Active, + /// The personal session is revoked, which means no more access tokens + /// can be generated and none are active. + Revoked, +} + +impl<'a> PersonalSessionFilter<'a> { + /// Create a new [`PersonalSessionFilter`] with default values + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// List sessions owned by a specific user + #[must_use] + pub fn for_owner_user(mut self, user: &'a User) -> Self { + self.owner_user = Some(user); + self + } + + /// Get the owner user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn owner_oauth2_client(&self) -> Option<&'a Client> { + self.owner_oauth2_client + } + + /// List sessions owned by a specific user + #[must_use] + pub fn for_owner_oauth2_client(mut self, client: &'a Client) -> Self { + self.owner_oauth2_client = Some(client); + self + } + + /// Get the owner user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn owner_user(&self) -> Option<&'a User> { + self.owner_user + } + + /// List sessions acting as a specific user + #[must_use] + pub fn for_actor_user(mut self, user: &'a User) -> Self { + self.actor_user = Some(user); + self + } + + /// Get the actor user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn actor_user(&self) -> Option<&'a User> { + self.actor_user + } + + /// Only return sessions with a last active time before the given time + #[must_use] + pub fn with_last_active_before(mut self, last_active_before: DateTime) -> Self { + self.last_active_before = Some(last_active_before); + self + } + + /// Only return sessions with a last active time after the given time + #[must_use] + pub fn with_last_active_after(mut self, last_active_after: DateTime) -> Self { + self.last_active_after = Some(last_active_after); + self + } + + /// Get the last active before filter + /// + /// Returns [`None`] if no client filter was set + #[must_use] + pub fn last_active_before(&self) -> Option> { + self.last_active_before + } + + /// Get the last active after filter + /// + /// Returns [`None`] if no client filter was set + #[must_use] + pub fn last_active_after(&self) -> Option> { + self.last_active_after + } + + /// Only return active sessions + #[must_use] + pub fn active_only(mut self) -> Self { + self.state = Some(PersonalSessionState::Active); + self + } + + /// Only return finished sessions + #[must_use] + pub fn finished_only(mut self) -> Self { + self.state = Some(PersonalSessionState::Revoked); + self + } + + /// Get the state filter + /// + /// Returns [`None`] if no state filter was set + #[must_use] + pub fn state(&self) -> Option { + self.state + } + + /// Only return sessions with the given scope + #[must_use] + pub fn with_scope(mut self, scope: &'a Scope) -> Self { + self.scope = Some(scope); + self + } + + /// Get the scope filter + /// + /// Returns [`None`] if no scope filter was set + #[must_use] + pub fn scope(&self) -> Option<&'a Scope> { + self.scope + } + + /// Only return sessions that have the given device in their scope + #[must_use] + pub fn for_device(mut self, device: &'a Device) -> Self { + self.device = Some(device); + self + } + + /// Get the device filter + /// + /// Returns [`None`] if no device filter was set + #[must_use] + pub fn device(&self) -> Option<&'a Device> { + self.device + } + + /// Only return sessions whose access tokens expire before the given time + #[must_use] + pub fn with_expires_before(mut self, expires_before: DateTime) -> Self { + self.expires_before = Some(expires_before); + self + } + + /// Get the expires before filter + /// + /// Returns [`None`] if no expires before filter was set + #[must_use] + pub fn expires_before(&self) -> Option> { + self.expires_before + } + + /// Only return sessions whose access tokens expire after the given time + #[must_use] + pub fn with_expires_after(mut self, expires_after: DateTime) -> Self { + self.expires_after = Some(expires_after); + self + } + + /// Get the expires after filter + /// + /// Returns [`None`] if no expires after filter was set + #[must_use] + pub fn expires_after(&self) -> Option> { + self.expires_after + } + + /// Only return sessions whose access tokens have, or don't have, + /// an expiry time set + #[must_use] + pub fn with_expires(mut self, expires: bool) -> Self { + self.expires = Some(expires); + self + } + + /// Get the expires filter + /// + /// Returns [`None`] if no expires filter was set + #[must_use] + pub fn expires(&self) -> Option { + self.expires + } +} diff --git a/crates/storage/src/policy_data.rs b/crates/storage/src/policy_data.rs index 6c7e5d89f..68f0040ab 100644 --- a/crates/storage/src/policy_data.rs +++ b/crates/storage/src/policy_data.rs @@ -1,15 +1,15 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repositories to interact with the policy data saved in the storage backend. use async_trait::async_trait; -use mas_data_model::PolicyData; +use mas_data_model::{Clock, PolicyData}; use rand_core::RngCore; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`PolicyDataRepository`] helps interacting with the policy data saved in /// the storage backend. diff --git a/crates/storage/src/queue/job.rs b/crates/storage/src/queue/job.rs index bc07c3b83..1fd21f442 100644 --- a/crates/storage/src/queue/job.rs +++ b/crates/storage/src/queue/job.rs @@ -1,12 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repository to interact with jobs in the job queue use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; +use mas_data_model::Clock; use opentelemetry::trace::TraceContextExt; use rand_core::RngCore; use serde::{Deserialize, Serialize}; @@ -14,7 +15,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt; use ulid::Ulid; use super::Worker; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// Represents a job in the job queue pub struct Job { diff --git a/crates/storage/src/queue/mod.rs b/crates/storage/src/queue/mod.rs index 03d969bbb..958ae13d0 100644 --- a/crates/storage/src/queue/mod.rs +++ b/crates/storage/src/queue/mod.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! A module containing repositories for the job queue diff --git a/crates/storage/src/queue/schedule.rs b/crates/storage/src/queue/schedule.rs index aaa83e5d2..32c225eca 100644 --- a/crates/storage/src/queue/schedule.rs +++ b/crates/storage/src/queue/schedule.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repository to interact with recurrent scheduled jobs in the job queue diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index b0075f319..3558314cf 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use chrono::{DateTime, Utc}; use mas_data_model::{ @@ -384,7 +384,7 @@ impl ExpireInactiveOAuthSessionsJob { let last_edge = page.edges.last()?; Some(Self { threshold: self.threshold, - after: Some(last_edge.id), + after: Some(last_edge.cursor), }) } } @@ -441,7 +441,7 @@ impl ExpireInactiveCompatSessionsJob { let last_edge = page.edges.last()?; Some(Self { threshold: self.threshold, - after: Some(last_edge.id), + after: Some(last_edge.cursor), }) } } @@ -498,7 +498,7 @@ impl ExpireInactiveUserSessionsJob { let last_edge = page.edges.last()?; Some(Self { threshold: self.threshold, - after: Some(last_edge.id), + after: Some(last_edge.cursor), }) } } diff --git a/crates/storage/src/queue/worker.rs b/crates/storage/src/queue/worker.rs index 23fa96104..c937134fe 100644 --- a/crates/storage/src/queue/worker.rs +++ b/crates/storage/src/queue/worker.rs @@ -1,16 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repository to interact with workers in the job queue use async_trait::async_trait; use chrono::Duration; +use mas_data_model::Clock; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A worker is an entity which can execute jobs. pub struct Worker { diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index a02edb4ad..f6eb191e6 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use futures_util::future::BoxFuture; @@ -18,6 +18,7 @@ use crate::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::{PersonalAccessTokenRepository, PersonalSessionRepository}, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -214,6 +215,16 @@ pub trait RepositoryAccess: Send { &'c mut self, ) -> Box + 'c>; + /// Get a [`PersonalAccessTokenRepository`] + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c>; + + /// Get a [`PersonalSessionRepository`] + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c>; + /// Get a [`QueueWorkerRepository`] fn queue_worker<'c>(&'c mut self) -> Box + 'c>; @@ -247,6 +258,7 @@ mod impls { OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::{PersonalAccessTokenRepository, PersonalSessionRepository}, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -458,6 +470,21 @@ mod impls { )) } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new( + self.inner.personal_access_token(), + &mut self.mapper, + )) + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new(self.inner.personal_session(), &mut self.mapper)) + } + fn queue_worker<'c>( &'c mut self, ) -> Box + 'c> { @@ -610,6 +637,18 @@ mod impls { (**self).compat_refresh_token() } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).personal_access_token() + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).personal_session() + } + fn queue_worker<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage/src/upstream_oauth2/link.rs b/crates/storage/src/upstream_oauth2/link.rs index cca070d86..092ad8d31 100644 --- a/crates/storage/src/upstream_oauth2/link.rs +++ b/crates/storage/src/upstream_oauth2/link.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{UpstreamOAuthLink, UpstreamOAuthProvider, User}; +use mas_data_model::{Clock, UpstreamOAuthLink, UpstreamOAuthProvider, User}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, Pagination, pagination::Page, repository_impl}; +use crate::{Pagination, pagination::Page, repository_impl}; /// Filter parameters for listing upstream OAuth links #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] diff --git a/crates/storage/src/upstream_oauth2/mod.rs b/crates/storage/src/upstream_oauth2/mod.rs index c2f72f278..39fefffe8 100644 --- a/crates/storage/src/upstream_oauth2/mod.rs +++ b/crates/storage/src/upstream_oauth2/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repositories to interact with entities related to the upstream OAuth 2.0 //! providers @@ -16,5 +16,5 @@ pub use self::{ provider::{ UpstreamOAuthProviderFilter, UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository, }, - session::UpstreamOAuthSessionRepository, + session::{UpstreamOAuthSessionFilter, UpstreamOAuthSessionRepository}, }; diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index ac6553b88..256a74968 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -1,14 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::marker::PhantomData; use async_trait::async_trait; use mas_data_model::{ - UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, + Clock, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode, UpstreamOAuthProviderTokenAuthMethod, }; @@ -18,7 +19,7 @@ use rand_core::RngCore; use ulid::Ulid; use url::Url; -use crate::{Clock, Pagination, pagination::Page, repository_impl}; +use crate::{Pagination, pagination::Page, repository_impl}; /// Structure which holds parameters when inserting or updating an upstream /// OAuth 2.0 provider @@ -101,6 +102,9 @@ pub struct UpstreamOAuthProviderParams { /// The position of the provider in the UI pub ui_order: i32, + + /// The behavior when receiving a backchannel logout notification + pub on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout, } /// Filter parameters for listing upstream OAuth 2.0 providers diff --git a/crates/storage/src/upstream_oauth2/session.rs b/crates/storage/src/upstream_oauth2/session.rs index c563fce5e..f9a902ec2 100644 --- a/crates/storage/src/upstream_oauth2/session.rs +++ b/crates/storage/src/upstream_oauth2/session.rs @@ -1,15 +1,78 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{UpstreamOAuthAuthorizationSession, UpstreamOAuthLink, UpstreamOAuthProvider}; +use mas_data_model::{ + Clock, UpstreamOAuthAuthorizationSession, UpstreamOAuthLink, UpstreamOAuthProvider, +}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::{Pagination, pagination::Page, repository_impl}; + +/// Filter parameters for listing upstream OAuth sessions +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct UpstreamOAuthSessionFilter<'a> { + provider: Option<&'a UpstreamOAuthProvider>, + sub_claim: Option<&'a str>, + sid_claim: Option<&'a str>, +} + +impl<'a> UpstreamOAuthSessionFilter<'a> { + /// Create a new [`UpstreamOAuthSessionFilter`] with default values + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the upstream OAuth provider for which to list sessions + #[must_use] + pub fn for_provider(mut self, provider: &'a UpstreamOAuthProvider) -> Self { + self.provider = Some(provider); + self + } + + /// Get the upstream OAuth provider filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn provider(&self) -> Option<&UpstreamOAuthProvider> { + self.provider + } + + /// Set the `sub` claim to filter by + #[must_use] + pub fn with_sub_claim(mut self, sub_claim: &'a str) -> Self { + self.sub_claim = Some(sub_claim); + self + } + + /// Get the `sub` claim filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn sub_claim(&self) -> Option<&str> { + self.sub_claim + } + + /// Set the `sid` claim to filter by + #[must_use] + pub fn with_sid_claim(mut self, sid_claim: &'a str) -> Self { + self.sid_claim = Some(sid_claim); + self + } + + /// Get the `sid` claim filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn sid_claim(&self) -> Option<&str> { + self.sid_claim + } +} /// An [`UpstreamOAuthSessionRepository`] helps interacting with /// [`UpstreamOAuthAuthorizationSession`] saved in the storage backend @@ -74,18 +137,23 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync { /// * `upstream_oauth_link`: the link to associate with the session /// * `id_token`: the ID token returned by the upstream OAuth provider, if /// present + /// * `id_token_claims`: the claims contained in the ID token, if present /// * `extra_callback_parameters`: the extra query parameters returned in /// the callback, if any + /// * `userinfo`: the user info returned by the upstream OAuth provider, if + /// requested /// /// # Errors /// /// Returns [`Self::Error`] if the underlying repository fails + #[expect(clippy::too_many_arguments)] async fn complete_with_link( &mut self, clock: &dyn Clock, upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result; @@ -107,6 +175,36 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync { clock: &dyn Clock, upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, ) -> Result; + + /// List [`UpstreamOAuthAuthorizationSession`] with the given filter and + /// pagination + /// + /// # Parameters + /// + /// * `filter`: The filter to apply + /// * `pagination`: The pagination parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn list( + &mut self, + filter: UpstreamOAuthSessionFilter<'_>, + pagination: Pagination, + ) -> Result, Self::Error>; + + /// Count the number of [`UpstreamOAuthAuthorizationSession`] with the given + /// filter + /// + /// # Parameters + /// + /// * `filter`: The filter to apply + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn count(&mut self, filter: UpstreamOAuthSessionFilter<'_>) + -> Result; } repository_impl!(UpstreamOAuthSessionRepository: @@ -131,6 +229,7 @@ repository_impl!(UpstreamOAuthSessionRepository: upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result; @@ -140,4 +239,12 @@ repository_impl!(UpstreamOAuthSessionRepository: clock: &dyn Clock, upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, ) -> Result; + + async fn list( + &mut self, + filter: UpstreamOAuthSessionFilter<'_>, + pagination: Pagination, + ) -> Result, Self::Error>; + + async fn count(&mut self, filter: UpstreamOAuthSessionFilter<'_>) -> Result; ); diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 4cdc8d665..7e973510a 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -1,18 +1,18 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use mas_data_model::{ - BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + BrowserSession, Clock, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRegistration, }; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, Pagination, pagination::Page, repository_impl}; +use crate::{Pagination, pagination::Page, repository_impl}; /// Filter parameters for listing user emails #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -36,6 +36,8 @@ impl<'a> UserEmailFilter<'a> { } /// Filter for emails matching a specific email address + /// + /// The email address is case-insensitive #[must_use] pub fn for_email(mut self, email: &'a str) -> Self { self.email = Some(email); @@ -81,6 +83,8 @@ pub trait UserEmailRepository: Send + Sync { /// Lookup an [`UserEmail`] by its email address for a [`User`] /// + /// The email address is case-insensitive + /// /// Returns `None` if no matching [`UserEmail`] was found /// /// # Parameters @@ -95,6 +99,8 @@ pub trait UserEmailRepository: Send + Sync { /// Lookup an [`UserEmail`] by its email address /// + /// The email address is case-insensitive + /// /// Returns `None` if no matching [`UserEmail`] was found or if multiple /// [`UserEmail`] are found /// diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 17852f0e9..657909ef4 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -1,17 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Repositories to interact with entities related to user accounts use async_trait::async_trait; -use mas_data_model::User; +use mas_data_model::{Clock, User}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, Page, Pagination, repository_impl}; +use crate::{Page, Pagination, repository_impl}; mod email; mod password; @@ -75,10 +75,11 @@ impl UserState { pub struct UserFilter<'a> { state: Option, can_request_admin: Option, - _phantom: std::marker::PhantomData<&'a ()>, + is_guest: Option, + search: Option<&'a str>, } -impl UserFilter<'_> { +impl<'a> UserFilter<'a> { /// Create a new [`UserFilter`] with default values #[must_use] pub fn new() -> Self { @@ -120,6 +121,27 @@ impl UserFilter<'_> { self } + /// Filter for guest users + #[must_use] + pub fn guest_only(mut self) -> Self { + self.is_guest = Some(true); + self + } + + /// Filter for non-guest users + #[must_use] + pub fn non_guest_only(mut self) -> Self { + self.is_guest = Some(false); + self + } + + /// Filter for users that match the given search string + #[must_use] + pub fn matching_search(mut self, search: &'a str) -> Self { + self.search = Some(search); + self + } + /// Get the state filter /// /// Returns [`None`] if no state filter was set @@ -135,6 +157,22 @@ impl UserFilter<'_> { pub fn can_request_admin(&self) -> Option { self.can_request_admin } + + /// Get the is guest filter + /// + /// Returns [`None`] if no is guest filter was set + #[must_use] + pub fn is_guest(&self) -> Option { + self.is_guest + } + + /// Get the search filter + /// + /// Returns [`None`] if no search filter was set + #[must_use] + pub fn search(&self) -> Option<&'a str> { + self.search + } } /// A [`UserRepository`] helps interacting with [`User`] saved in the storage @@ -244,6 +282,19 @@ pub trait UserRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result; + /// Reactivate a [`User`] + /// + /// Returns the reactivated [`User`] + /// + /// # Parameters + /// + /// * `user`: The [`User`] to reactivate + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn reactivate(&mut self, user: User) -> Result; + /// Set whether a [`User`] can request admin /// /// Returns the [`User`] with the new `can_request_admin` value @@ -315,6 +366,7 @@ repository_impl!(UserRepository: async fn lock(&mut self, clock: &dyn Clock, user: User) -> Result; async fn unlock(&mut self, user: User) -> Result; async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result; + async fn reactivate(&mut self, user: User) -> Result; async fn set_can_request_admin( &mut self, user: User, diff --git a/crates/storage/src/user/password.rs b/crates/storage/src/user/password.rs index fe7115d97..fff0298e9 100644 --- a/crates/storage/src/user/password.rs +++ b/crates/storage/src/user/password.rs @@ -1,14 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{Password, User}; +use mas_data_model::{Clock, Password, User}; use rand_core::RngCore; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`UserPasswordRepository`] helps interacting with [`Password`] saved in /// the storage backend diff --git a/crates/storage/src/user/recovery.rs b/crates/storage/src/user/recovery.rs index a5361e795..2bed10dfc 100644 --- a/crates/storage/src/user/recovery.rs +++ b/crates/storage/src/user/recovery.rs @@ -1,17 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; -use mas_data_model::{UserEmail, UserRecoverySession, UserRecoveryTicket}; +use mas_data_model::{Clock, UserEmail, UserRecoverySession, UserRecoveryTicket}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`UserRecoveryRepository`] helps interacting with [`UserRecoverySession`] /// and [`UserRecoveryTicket`] saved in the storage backend diff --git a/crates/storage/src/user/registration.rs b/crates/storage/src/user/registration.rs index 49fd01fdc..0d32684d4 100644 --- a/crates/storage/src/user/registration.rs +++ b/crates/storage/src/user/registration.rs @@ -1,17 +1,17 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; -use mas_data_model::{UserEmailAuthentication, UserRegistration, UserRegistrationToken}; +use mas_data_model::{Clock, UserEmailAuthentication, UserRegistration, UserRegistrationToken}; use rand_core::RngCore; use ulid::Ulid; use url::Url; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`UserRegistrationRepository`] helps interacting with [`UserRegistration`] /// saved in the storage backend diff --git a/crates/storage/src/user/registration_token.rs b/crates/storage/src/user/registration_token.rs index e3913b5d8..1fb98550e 100644 --- a/crates/storage/src/user/registration_token.rs +++ b/crates/storage/src/user/registration_token.rs @@ -1,15 +1,15 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::UserRegistrationToken; +use mas_data_model::{Clock, UserRegistrationToken}; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A filter to apply when listing [`UserRegistrationToken`]s #[derive(Debug, Clone, Copy)] diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index 2421ff009..155556454 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -1,20 +1,22 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, + Authentication, BrowserSession, Clock, Password, UpstreamOAuthAuthorizationSession, User, }; use rand_core::RngCore; use ulid::Ulid; -use crate::{Clock, Pagination, pagination::Page, repository_impl}; +use crate::{ + Pagination, pagination::Page, repository_impl, upstream_oauth2::UpstreamOAuthSessionFilter, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BrowserSessionState { @@ -39,6 +41,7 @@ pub struct BrowserSessionFilter<'a> { state: Option, last_active_before: Option>, last_active_after: Option>, + authenticated_by_upstream_sessions: Option>, } impl<'a> BrowserSessionFilter<'a> { @@ -110,6 +113,23 @@ impl<'a> BrowserSessionFilter<'a> { pub fn state(&self) -> Option { self.state } + + /// Only return browser sessions authenticated by the given upstream OAuth + /// sessions + #[must_use] + pub fn authenticated_by_upstream_sessions_only( + mut self, + filter: UpstreamOAuthSessionFilter<'a>, + ) -> Self { + self.authenticated_by_upstream_sessions = Some(filter); + self + } + + /// Get the upstream OAuth session filter + #[must_use] + pub fn authenticated_by_upstream_sessions(&self) -> Option> { + self.authenticated_by_upstream_sessions + } } /// A [`BrowserSessionRepository`] helps interacting with [`BrowserSession`] diff --git a/crates/storage/src/user/terms.rs b/crates/storage/src/user/terms.rs index 34a6d3a6b..155360973 100644 --- a/crates/storage/src/user/terms.rs +++ b/crates/storage/src/user/terms.rs @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; -use mas_data_model::User; +use mas_data_model::{Clock, User}; use rand_core::RngCore; use url::Url; -use crate::{Clock, repository_impl}; +use crate::repository_impl; /// A [`UserTermsRepository`] helps interacting with the terms of service agreed /// by a [`User`] diff --git a/crates/storage/src/utils.rs b/crates/storage/src/utils.rs index 9f5d7ab98..a6f380fe7 100644 --- a/crates/storage/src/utils.rs +++ b/crates/storage/src/utils.rs @@ -1,15 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Wrappers and useful type aliases -use rand_core::CryptoRngCore; - -use crate::Clock; - /// A wrapper which is used to map the error type of a repository to another pub struct MapErr { pub(crate) inner: R, @@ -30,12 +26,6 @@ impl MapErr { } } -/// A boxed [`Clock`] -pub type BoxClock = Box; - -/// A boxed random number generator -pub type BoxRng = Box; - /// A macro to implement a repository trait for the [`MapErr`] wrapper and for /// [`Box`] #[macro_export] diff --git a/crates/syn2mas/Cargo.toml b/crates/syn2mas/Cargo.toml index 5452c14be..ca278bee2 100644 --- a/crates/syn2mas/Cargo.toml +++ b/crates/syn2mas/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "syn2mas" version.workspace = true @@ -23,6 +28,7 @@ futures-util.workspace = true mas-config.workspace = true mas-iana.workspace = true mas-storage.workspace = true +mas-data-model.workspace = true oauth2-types.workspace = true opentelemetry-semantic-conventions.workspace = true opentelemetry.workspace = true diff --git a/crates/syn2mas/src/lib.rs b/crates/syn2mas/src/lib.rs index 703e53150..3593858d7 100644 --- a/crates/syn2mas/src/lib.rs +++ b/crates/syn2mas/src/lib.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod mas_writer; mod synapse_reader; diff --git a/crates/syn2mas/src/mas_writer/checks.rs b/crates/syn2mas/src/mas_writer/checks.rs index 288156d8c..ae0964bff 100644 --- a/crates/syn2mas/src/mas_writer/checks.rs +++ b/crates/syn2mas/src/mas_writer/checks.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! # MAS Database Checks //! diff --git a/crates/syn2mas/src/mas_writer/constraint_pausing.rs b/crates/syn2mas/src/mas_writer/constraint_pausing.rs index 49fd4a8e3..3bfef602f 100644 --- a/crates/syn2mas/src/mas_writer/constraint_pausing.rs +++ b/crates/syn2mas/src/mas_writer/constraint_pausing.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::time::Instant; diff --git a/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql b/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql index 9da09b174..957cedcb3 100644 --- a/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql +++ b/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO upstream_oauth_providers ( diff --git a/crates/syn2mas/src/mas_writer/locking.rs b/crates/syn2mas/src/mas_writer/locking.rs index 8200924d4..96fd2d30f 100644 --- a/crates/syn2mas/src/mas_writer/locking.rs +++ b/crates/syn2mas/src/mas_writer/locking.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; diff --git a/crates/syn2mas/src/mas_writer/mod.rs b/crates/syn2mas/src/mas_writer/mod.rs index f36851dfd..6d59ec4b9 100644 --- a/crates/syn2mas/src/mas_writer/mod.rs +++ b/crates/syn2mas/src/mas_writer/mod.rs @@ -1,7 +1,7 @@ // Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! # MAS Writer //! diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap index a368aa9a5..adb6d4ee4 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap @@ -25,6 +25,7 @@ upstream_oauth_providers: id_token_signed_response_alg: RS256 issuer: ~ jwks_uri_override: ~ + on_backchannel_logout: do_nothing pkce_mode: auto response_mode: query scope: openid diff --git a/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql b/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql index e1df06f94..73f4cfe52 100644 --- a/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql +++ b/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql @@ -1,7 +1,7 @@ -- Copyright 2024 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. -- This script should revert what `syn2mas_temporary_tables.sql` does. diff --git a/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql b/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql index 9cda82881..873ceeb7e 100644 --- a/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql +++ b/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql @@ -1,7 +1,7 @@ -- Copyright 2024 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. -- # syn2mas Temporary Tables diff --git a/crates/syn2mas/src/migration.rs b/crates/syn2mas/src/migration.rs index 2a906d933..e32bfd6b9 100644 --- a/crates/syn2mas/src/migration.rs +++ b/crates/syn2mas/src/migration.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! # Migration //! @@ -16,7 +16,7 @@ use std::time::Instant; use chrono::{DateTime, Utc}; use compact_str::CompactString; use futures_util::{SinkExt, StreamExt as _, TryFutureExt, TryStreamExt as _}; -use mas_storage::Clock; +use mas_data_model::Clock; use rand::{RngCore, SeedableRng}; use thiserror::Error; use thiserror_ext::ContextInto; diff --git a/crates/syn2mas/src/progress.rs b/crates/syn2mas/src/progress.rs index 3c67825ce..cdd7ab417 100644 --- a/crates/syn2mas/src/progress.rs +++ b/crates/syn2mas/src/progress.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock, atomic::AtomicU32}; diff --git a/crates/syn2mas/src/synapse_reader/checks.rs b/crates/syn2mas/src/synapse_reader/checks.rs index a78f18b1d..aee91b62a 100644 --- a/crates/syn2mas/src/synapse_reader/checks.rs +++ b/crates/syn2mas/src/synapse_reader/checks.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! # Synapse Checks //! @@ -25,7 +25,7 @@ pub enum Error { Sqlx(#[from] sqlx::Error), #[error("failed to load MAS config: {0}")] - MasConfig(#[from] figment::Error), + MasConfig(#[source] Box), #[error("failed to load MAS password config: {0}")] MasPasswordConfig(#[source] anyhow::Error), @@ -188,13 +188,13 @@ pub async fn synapse_config_check_against_mas_config( let mut errors = Vec::new(); let mut warnings = Vec::new(); - let mas_passwords = PasswordsConfig::extract_or_default(mas)?; + let mas_passwords = PasswordsConfig::extract_or_default(mas).map_err(Error::MasConfig)?; let mas_password_schemes = mas_passwords .load() .await .map_err(Error::MasPasswordConfig)?; - let mas_matrix = MatrixConfig::extract(mas)?; + let mas_matrix = MatrixConfig::extract(mas).map_err(Error::MasConfig)?; // Look for the MAS password hashing scheme that will be used for imported // Synapse passwords, then check the configuration matches so that Synapse @@ -230,12 +230,12 @@ pub async fn synapse_config_check_against_mas_config( }); } - let mas_captcha = CaptchaConfig::extract_or_default(mas)?; + let mas_captcha = CaptchaConfig::extract_or_default(mas).map_err(Error::MasConfig)?; if synapse.enable_registration_captcha && mas_captcha.service.is_none() { warnings.push(CheckWarning::ShouldPortRegistrationCaptcha); } - let mas_branding = BrandingConfig::extract_or_default(mas)?; + let mas_branding = BrandingConfig::extract_or_default(mas).map_err(Error::MasConfig)?; if synapse.user_consent.is_some() && mas_branding.tos_uri.is_none() { warnings.push(CheckWarning::ShouldPortUserConsentAsTerms); } @@ -250,7 +250,8 @@ pub async fn synapse_config_check_against_mas_config( /// /// - If there is some database connection error, or the given database is not a /// Synapse database. -/// - If the OAuth2 section of the MAS configuration could not be parsed. +/// - If the Upstream OAuth section of the MAS configuration could not be +/// parsed. #[tracing::instrument(skip_all)] pub async fn synapse_database_check( synapse_connection: &mut PgConnection, @@ -295,7 +296,7 @@ pub async fn synapse_database_check( .await?; if !oauth_provider_user_counts.is_empty() { let syn_oauth2 = synapse.all_oidc_providers(); - let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas)?; + let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas).map_err(Error::MasConfig)?; for row in oauth_provider_user_counts { // This is a special case of a previous migration attempt to MAS if row.auth_provider == "oauth-delegated" { diff --git a/crates/syn2mas/src/synapse_reader/config/mod.rs b/crates/syn2mas/src/synapse_reader/config/mod.rs index 25a4c0d93..3c9454ba6 100644 --- a/crates/syn2mas/src/synapse_reader/config/mod.rs +++ b/crates/syn2mas/src/synapse_reader/config/mod.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod oidc; @@ -94,7 +94,9 @@ impl Config { /// /// - If there is a problem reading any of the files. /// - If the configuration is not valid. - pub fn load(files: &[Utf8PathBuf]) -> Result { + pub fn load( + files: &[Utf8PathBuf], + ) -> Result> { let mut figment = figment::Figment::new(); for file in files { // TODO this is not exactly correct behaviour — Synapse does not merge anything @@ -103,7 +105,8 @@ impl Config { // https://github.com/element-hq/synapse/blob/develop/synapse/config/_base.py?rgh-link-date=2025-01-20T17%3A02%3A56Z#L870 figment = figment.merge(Yaml::file(file)); } - figment.extract::() + let config = figment.extract::()?; + Ok(config) } /// Returns a map of all OIDC providers from the Synapse configuration. @@ -117,14 +120,14 @@ impl Config { pub fn all_oidc_providers(&self) -> BTreeMap { let mut out = BTreeMap::new(); - if let Some(provider) = &self.oidc_config { - if provider.has_required_fields() { - let mut provider = provider.clone(); - // The legacy configuration has an implied IdP ID of `oidc`. - let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned()); - provider.idp_id = Some(idp_id.clone()); - out.insert(idp_id, provider); - } + if let Some(provider) = &self.oidc_config + && provider.has_required_fields() + { + let mut provider = provider.clone(); + // The legacy configuration has an implied IdP ID of `oidc`. + let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned()); + provider.idp_id = Some(idp_id.clone()); + out.insert(idp_id, provider); } for provider in &self.oidc_providers { diff --git a/crates/syn2mas/src/synapse_reader/config/oidc.rs b/crates/syn2mas/src/synapse_reader/config/oidc.rs index 9eea0a9be..49ee59a71 100644 --- a/crates/syn2mas/src/synapse_reader/config/oidc.rs +++ b/crates/syn2mas/src/synapse_reader/config/oidc.rs @@ -1,14 +1,15 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::BTreeMap, str::FromStr as _}; use chrono::{DateTime, Utc}; use mas_config::{ UpstreamOAuth2ClaimsImports, UpstreamOAuth2DiscoveryMode, UpstreamOAuth2ImportAction, - UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, UpstreamOAuth2TokenAuthMethod, + UpstreamOAuth2OnBackchannelLogout, UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, + UpstreamOAuth2TokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use oauth2_types::scope::{OPENID, Scope, ScopeToken}; @@ -159,7 +160,6 @@ pub struct OidcProvider { #[serde(default)] skip_verification: bool, - // Unsupported, we want to shout about it #[serde(default)] backchannel_logout_enabled: bool, @@ -193,7 +193,6 @@ impl OidcProvider { } /// Map this Synapse OIDC provider config to a MAS upstream provider config. - #[expect(clippy::too_many_lines)] pub(crate) fn into_mas_config( self, rng: &mut impl Rng, @@ -219,10 +218,6 @@ impl OidcProvider { warn!("The `id_token_signing_alg_values_supported` option is not supported, ignoring."); } - if self.backchannel_logout_enabled { - warn!("The `backchannel_logout_enabled` option is not supported, ignoring."); - } - if !self.enable_registration { warn!( "Setting the `enable_registration` option to `false` is not supported, ignoring." @@ -319,6 +314,12 @@ impl OidcProvider { self.user_mapping_provider.config.into_mas_config() }; + let on_backchannel_logout = if self.backchannel_logout_enabled { + UpstreamOAuth2OnBackchannelLogout::DoNothing + } else { + UpstreamOAuth2OnBackchannelLogout::LogoutBrowserOnly + }; + Some(mas_config::UpstreamOAuth2Provider { enabled: true, id, @@ -345,6 +346,7 @@ impl OidcProvider { claims_imports, additional_authorization_parameters, forward_login_hint: self.forward_login_hint, + on_backchannel_logout, }) } } diff --git a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice.sql index e92fd21bf..4926445b9 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO access_tokens ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_puppet.sql b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_puppet.sql index c8b2850ac..6029f94fe 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_puppet.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_puppet.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO access_tokens ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_refresh_token.sql b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_refresh_token.sql index 180a58810..bba684b1a 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_refresh_token.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_refresh_token.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO access_tokens ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_unused_refresh_token.sql b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_unused_refresh_token.sql index 8c7d1c695..e1de6b287 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_unused_refresh_token.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice_with_unused_refresh_token.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO access_tokens ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql index 8eb50a3ba..411c6ba30 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO devices ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql index a365faf05..651f03cf9 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO user_external_ids ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql index 4bf680cce..5b643a462 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO user_threepids ( diff --git a/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql index dc77d5859..825213402 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql @@ -1,7 +1,7 @@ -- Copyright 2024, 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. INSERT INTO users ( diff --git a/crates/syn2mas/src/synapse_reader/mod.rs b/crates/syn2mas/src/synapse_reader/mod.rs index 0b753e33b..b5b691495 100644 --- a/crates/syn2mas/src/synapse_reader/mod.rs +++ b/crates/syn2mas/src/synapse_reader/mod.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! # Synapse Database Reader //! diff --git a/crates/syn2mas/src/telemetry.rs b/crates/syn2mas/src/telemetry.rs index e9a3385fb..8d67d4bf4 100644 --- a/crates/syn2mas/src/telemetry.rs +++ b/crates/syn2mas/src/telemetry.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::LazyLock; diff --git a/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql b/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql index 5c67dc097..72cc46dab 100644 --- a/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql +++ b/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql @@ -1,26 +1,23 @@ -- Copyright 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. - +-- Please see LICENSE files in the repository root for full details. -- Brings in the `users` table from Synapse - CREATE TABLE users ( - name text, - password_hash text, - creation_ts bigint, - admin smallint DEFAULT 0 NOT NULL, - upgrade_ts bigint, - is_guest smallint DEFAULT 0 NOT NULL, - appservice_id text, - consent_version text, - consent_server_notice_sent text, - user_type text, - deactivated smallint DEFAULT 0 NOT NULL, - shadow_banned boolean, - consent_ts bigint, - approved boolean, - locked boolean DEFAULT false NOT NULL, - suspended boolean DEFAULT false NOT NULL + name text, + password_hash text, + creation_ts bigint, + admin smallint DEFAULT 0 NOT NULL, + upgrade_ts bigint, + is_guest smallint DEFAULT 0 NOT NULL, + appservice_id text, + consent_version text, + consent_server_notice_sent text, + user_type text, + deactivated smallint DEFAULT 0 NOT NULL, + shadow_banned boolean, + consent_ts bigint, + approved boolean, + locked boolean DEFAULT false NOT NULL, + suspended boolean DEFAULT false NOT NULL ); - diff --git a/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql b/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql index 2ff655979..3ee382b35 100644 --- a/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql +++ b/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql @@ -1,7 +1,7 @@ -- Copyright 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. -- Brings in the `user_threepids` table from Synapse diff --git a/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql b/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql index 09eec8430..9054accd3 100644 --- a/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql +++ b/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql @@ -1,7 +1,7 @@ -- Copyright 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. -- Brings in the `user_external_ids` table from Synapse diff --git a/crates/syn2mas/test_synapse_migrations/20250128201100_access_and_refresh_tokens.sql b/crates/syn2mas/test_synapse_migrations/20250128201100_access_and_refresh_tokens.sql index fef25bbbb..8b1ed58ea 100644 --- a/crates/syn2mas/test_synapse_migrations/20250128201100_access_and_refresh_tokens.sql +++ b/crates/syn2mas/test_synapse_migrations/20250128201100_access_and_refresh_tokens.sql @@ -1,7 +1,7 @@ -- Copyright 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. -- Brings in the `access_tokens` and `refresh_tokens` tables from Synapse diff --git a/crates/syn2mas/test_synapse_migrations/20250129140230_devices.sql b/crates/syn2mas/test_synapse_migrations/20250129140230_devices.sql index 8f9ae723b..129df1b68 100644 --- a/crates/syn2mas/test_synapse_migrations/20250129140230_devices.sql +++ b/crates/syn2mas/test_synapse_migrations/20250129140230_devices.sql @@ -1,7 +1,7 @@ -- Copyright 2025 New Vector Ltd. -- -- SPDX-License-Identifier: AGPL-3.0-only --- Please see LICENSE in the repository root for full details. +-- Please see LICENSE files in the repository root for full details. -- Brings in the `devices` table from Synapse CREATE TABLE devices ( diff --git a/crates/tasks/Cargo.toml b/crates/tasks/Cargo.toml index 9cd39c20b..5c2a31536 100644 --- a/crates/tasks/Cargo.toml +++ b/crates/tasks/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-tasks" version.workspace = true diff --git a/crates/tasks/src/database.rs b/crates/tasks/src/database.rs index fa424d7df..bc14215f8 100644 --- a/crates/tasks/src/database.rs +++ b/crates/tasks/src/database.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Database-related tasks @@ -24,7 +24,7 @@ impl RunnableJob for CleanupExpiredTokensJob { let count = repo .oauth2_access_token() - .cleanup_revoked(&clock) + .cleanup_revoked(clock) .await .map_err(JobError::retry)?; repo.save().await.map_err(JobError::retry)?; diff --git a/crates/tasks/src/email.rs b/crates/tasks/src/email.rs index 4eacdfaf6..8e685843a 100644 --- a/crates/tasks/src/email.rs +++ b/crates/tasks/src/email.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use async_trait::async_trait; use chrono::Duration; @@ -100,7 +100,7 @@ impl RunnableJob for SendEmailAuthenticationCodeJob { .user_email() .add_authentication_code( &mut rng, - &clock, + clock, Duration::minutes(5), // TODO: make this configurable &user_email_authentication, code, diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index cb1b16469..a480a8d50 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -1,16 +1,16 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::{Arc, LazyLock}; -use mas_data_model::SiteConfig; +use mas_data_model::{Clock, SiteConfig}; use mas_email::Mailer; use mas_matrix::HomeserverConnection; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, RepositoryError, RepositoryFactory, SystemClock}; +use mas_storage::{BoxRepository, RepositoryError, RepositoryFactory}; use mas_storage_pg::PgRepositoryFactory; use new_queue::QueueRunnerError; use opentelemetry::metrics::Meter; @@ -18,6 +18,8 @@ use rand::SeedableRng; use sqlx::{Pool, Postgres}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; +pub use crate::new_queue::QueueWorker; + mod database; mod email; mod matrix; @@ -39,7 +41,7 @@ static METER: LazyLock = LazyLock::new(|| { struct State { repository_factory: PgRepositoryFactory, mailer: Mailer, - clock: SystemClock, + clock: Arc, homeserver: Arc, url_builder: UrlBuilder, site_config: SiteConfig, @@ -48,7 +50,7 @@ struct State { impl State { pub fn new( repository_factory: PgRepositoryFactory, - clock: SystemClock, + clock: impl Clock + 'static, mailer: Mailer, homeserver: impl HomeserverConnection + 'static, url_builder: UrlBuilder, @@ -57,7 +59,7 @@ impl State { Self { repository_factory, mailer, - clock, + clock: Arc::new(clock), homeserver: Arc::new(homeserver), url_builder, site_config, @@ -68,8 +70,8 @@ impl State { self.repository_factory.pool() } - pub fn clock(&self) -> BoxClock { - Box::new(self.clock.clone()) + pub fn clock(&self) -> &dyn Clock { + &self.clock } pub fn mailer(&self) -> &Mailer { @@ -99,29 +101,31 @@ impl State { } } -/// Initialise the workers. +/// Initialise the worker, without running it. +/// +/// This is mostly useful for tests. /// /// # Errors /// /// This function can fail if the database connection fails. pub async fn init( repository_factory: PgRepositoryFactory, + clock: impl Clock + 'static, mailer: &Mailer, homeserver: impl HomeserverConnection + 'static, url_builder: UrlBuilder, site_config: &SiteConfig, cancellation_token: CancellationToken, - task_tracker: &TaskTracker, -) -> Result<(), QueueRunnerError> { +) -> Result { let state = State::new( repository_factory, - SystemClock::default(), + clock, mailer.clone(), homeserver, url_builder, site_config.clone(), ); - let mut worker = self::new_queue::QueueWorker::new(state, cancellation_token).await?; + let mut worker = QueueWorker::new(state, cancellation_token).await?; worker .register_handler::() @@ -157,6 +161,36 @@ pub async fn init( mas_storage::queue::PruneStalePolicyDataJob, ); + Ok(worker) +} + +/// Initialise the worker and run it. +/// +/// # Errors +/// +/// This function can fail if the database connection fails. +#[expect(clippy::too_many_arguments, reason = "this is fine")] +pub async fn init_and_run( + repository_factory: PgRepositoryFactory, + clock: impl Clock + 'static, + mailer: &Mailer, + homeserver: impl HomeserverConnection + 'static, + url_builder: UrlBuilder, + site_config: &SiteConfig, + cancellation_token: CancellationToken, + task_tracker: &TaskTracker, +) -> Result<(), QueueRunnerError> { + let worker = init( + repository_factory, + clock, + mailer, + homeserver, + url_builder, + site_config, + cancellation_token, + ) + .await?; + task_tracker.spawn(worker.run()); Ok(()) diff --git a/crates/tasks/src/matrix.rs b/crates/tasks/src/matrix.rs index 3060b3d7b..68905fe53 100644 --- a/crates/tasks/src/matrix.rs +++ b/crates/tasks/src/matrix.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashSet; @@ -14,6 +14,7 @@ use mas_storage::{ Pagination, RepositoryAccess, compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, + personal::PersonalSessionFilter, queue::{ DeleteDeviceJob, ProvisionDeviceJob, ProvisionUserJob, QueueJobRepositoryExt as _, SyncDevicesJob, @@ -29,7 +30,7 @@ use crate::{ /// Job to provision a user on the Matrix homeserver. /// This works by doing a PUT request to the -/// /_synapse/admin/v2/users/{user_id} endpoint. +/// `/_synapse/admin/v2/users/{user_id}` endpoint. #[async_trait] impl RunnableJob for ProvisionUserJob { #[tracing::instrument( @@ -51,7 +52,6 @@ impl RunnableJob for ProvisionUserJob { .context("User not found") .map_err(JobError::fail)?; - let mxid = matrix.mxid(&user.username); let emails = repo .user_email() .all(&user) @@ -60,7 +60,8 @@ impl RunnableJob for ProvisionUserJob { .into_iter() .map(|email| email.email) .collect(); - let mut request = ProvisionRequest::new(mxid.clone(), user.sub.clone()).set_emails(emails); + let mut request = + ProvisionRequest::new(user.username.clone(), user.sub.clone()).set_emails(emails); if let Some(display_name) = self.display_name_to_set() { request = request.set_displayname(display_name.to_owned()); @@ -71,6 +72,7 @@ impl RunnableJob for ProvisionUserJob { .await .map_err(JobError::retry)?; + let mxid = matrix.mxid(&user.username); if created { info!(%user.id, %mxid, "User created"); } else { @@ -80,7 +82,7 @@ impl RunnableJob for ProvisionUserJob { // Schedule a device sync job let sync_device_job = SyncDevicesJob::new(&user); repo.queue_job() - .schedule_job(&mut rng, &clock, sync_device_job) + .schedule_job(&mut rng, clock, sync_device_job) .await .map_err(JobError::retry)?; @@ -118,7 +120,7 @@ impl RunnableJob for ProvisionDeviceJob { // Schedule a device sync job repo.queue_job() - .schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user)) + .schedule_job(&mut rng, clock, SyncDevicesJob::new(&user)) .await .map_err(JobError::retry)?; @@ -154,7 +156,7 @@ impl RunnableJob for DeleteDeviceJob { // Schedule a device sync job repo.queue_job() - .schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user)) + .schedule_job(&mut rng, clock, SyncDevicesJob::new(&user)) .await .map_err(JobError::retry)?; @@ -191,7 +193,7 @@ impl RunnableJob for SyncDevicesJob { let mut devices = HashSet::new(); // Cycle through all the compat sessions of the user, and grab the devices - let mut cursor = Pagination::first(100); + let mut cursor = Pagination::first(5000); loop { let page = repo .compat_session() @@ -202,11 +204,12 @@ impl RunnableJob for SyncDevicesJob { .await .map_err(JobError::retry)?; - for (compat_session, _) in page.edges { + for edge in page.edges { + let (compat_session, _) = edge.node; if let Some(ref device) = compat_session.device { devices.insert(device.as_str().to_owned()); } - cursor = cursor.after(compat_session.id); + cursor = cursor.after(edge.cursor); } if !page.has_next_page { @@ -215,7 +218,7 @@ impl RunnableJob for SyncDevicesJob { } // Cycle though all the oauth2 sessions of the user, and grab the devices - let mut cursor = Pagination::first(100); + let mut cursor = Pagination::first(5000); loop { let page = repo .oauth2_session() @@ -226,14 +229,44 @@ impl RunnableJob for SyncDevicesJob { .await .map_err(JobError::retry)?; - for oauth2_session in page.edges { - for scope in &*oauth2_session.scope { + for edge in page.edges { + for scope in &*edge.node.scope { if let Some(device) = Device::from_scope_token(scope) { devices.insert(device.as_str().to_owned()); } } - cursor = cursor.after(oauth2_session.id); + cursor = cursor.after(edge.cursor); + } + + if !page.has_next_page { + break; + } + } + + // Cycle through all the personal sessions of the user and get the devices + let mut cursor = Pagination::first(5000); + loop { + let page = repo + .personal_session() + .list( + PersonalSessionFilter::new() + .for_actor_user(&user) + .active_only(), + cursor, + ) + .await + .map_err(JobError::retry)?; + + for edge in page.edges { + let (session, _) = &edge.node; + for scope in &*session.scope { + if let Some(device) = Device::from_scope_token(scope) { + devices.insert(device.as_str().to_owned()); + } + } + + cursor = cursor.after(edge.cursor); } if !page.has_next_page { @@ -241,9 +274,8 @@ impl RunnableJob for SyncDevicesJob { } } - let mxid = matrix.mxid(&user.username); matrix - .sync_devices(&mxid, devices) + .sync_devices(&user.username, devices) .await .map_err(JobError::retry)?; diff --git a/crates/tasks/src/new_queue.rs b/crates/tasks/src/new_queue.rs index 1c83b1720..df0fb4cd7 100644 --- a/crates/tasks/src/new_queue.rs +++ b/crates/tasks/src/new_queue.rs @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, sync::Arc}; @@ -9,8 +9,9 @@ use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use cron::Schedule; use mas_context::LogContext; +use mas_data_model::Clock; use mas_storage::{ - Clock, RepositoryAccess, RepositoryError, + RepositoryAccess, RepositoryError, queue::{InsertableJob, Job, JobMetadata, Worker}, }; use mas_storage_pg::{DatabaseError, PgRepository}; @@ -19,7 +20,6 @@ use opentelemetry::{ metrics::{Counter, Histogram, UpDownCounter}, }; use rand::{Rng, RngCore, distributions::Uniform}; -use rand_chacha::ChaChaRng; use serde::de::DeserializeOwned; use sqlx::{ Acquire, Either, @@ -195,8 +195,6 @@ struct ScheduleDefinition { } pub struct QueueWorker { - rng: ChaChaRng, - clock: Box, listener: PgListener, registration: Worker, am_i_leader: bool, @@ -217,7 +215,7 @@ impl QueueWorker { skip_all, fields(worker.id) )] - pub async fn new( + pub(crate) async fn new( state: State, cancellation_token: CancellationToken, ) -> Result { @@ -246,7 +244,7 @@ impl QueueWorker { .map_err(QueueRunnerError::StartTransaction)?; let mut repo = PgRepository::from_conn(txn); - let registration = repo.queue_worker().register(&mut rng, &clock).await?; + let registration = repo.queue_worker().register(&mut rng, clock).await?; tracing::Span::current().record("worker.id", tracing::field::display(registration.id)); repo.into_inner() .commit() @@ -278,8 +276,6 @@ impl QueueWorker { let cancellation_guard = cancellation_token.clone().drop_guard(); Ok(Self { - rng, - clock, listener, registration, am_i_leader: false, @@ -294,7 +290,7 @@ impl QueueWorker { }) } - pub fn register_handler(&mut self) -> &mut Self { + pub(crate) fn register_handler(&mut self) -> &mut Self { // There is a potential panic here, which is fine as it's going to be caught // within the job task let factory = |payload: JobPayload| { @@ -307,7 +303,7 @@ impl QueueWorker { self } - pub fn add_schedule( + pub(crate) fn add_schedule( &mut self, schedule_name: &'static str, expression: Schedule, @@ -325,7 +321,7 @@ impl QueueWorker { self } - pub async fn run(mut self) { + pub(crate) async fn run(mut self) { if let Err(e) = self.run_inner().await { tracing::error!( error = &e as &dyn std::error::Error, @@ -349,7 +345,7 @@ impl QueueWorker { } #[tracing::instrument(name = "worker.setup_schedules", skip_all)] - pub async fn setup_schedules(&mut self) -> Result<(), QueueRunnerError> { + pub(crate) async fn setup_schedules(&mut self) -> Result<(), QueueRunnerError> { let schedules: Vec<_> = self.schedules.iter().map(|s| s.schedule_name).collect(); // Start a transaction on the existing PgListener connection @@ -397,6 +393,9 @@ impl QueueWorker { async fn shutdown(&mut self) -> Result<(), QueueRunnerError> { tracing::info!("Shutting down worker"); + let clock = self.state.clock(); + let mut rng = self.state.rng(); + // Start a transaction on the existing PgListener connection let txn = self .listener @@ -421,13 +420,13 @@ impl QueueWorker { // Wait for all the jobs to finish self.tracker - .process_jobs(&mut self.rng, &self.clock, &mut repo, true) + .process_jobs(&mut rng, clock, &mut repo, true) .await?; // Tell the other workers we're shutting down // This also releases the leader election lease repo.queue_worker() - .shutdown(&self.clock, &self.registration) + .shutdown(clock, &self.registration) .await?; repo.into_inner() @@ -440,12 +439,12 @@ impl QueueWorker { #[tracing::instrument(name = "worker.wait_until_wakeup", skip_all)] async fn wait_until_wakeup(&mut self) -> Result<(), QueueRunnerError> { + let mut rng = self.state.rng(); + // This is to make sure we wake up every second to do the maintenance tasks // We add a little bit of random jitter to the duration, so that we don't get // fully synced workers waking up at the same time after each notification - let sleep_duration = self - .rng - .sample(Uniform::new(MIN_SLEEP_DURATION, MAX_SLEEP_DURATION)); + let sleep_duration = rng.sample(Uniform::new(MIN_SLEEP_DURATION, MAX_SLEEP_DURATION)); let wakeup_sleep = tokio::time::sleep(sleep_duration); tokio::select! { @@ -490,7 +489,9 @@ impl QueueWorker { )] async fn tick(&mut self) -> Result<(), QueueRunnerError> { tracing::debug!("Tick"); - let now = self.clock.now(); + let clock = self.state.clock(); + let mut rng = self.state.rng(); + let now = clock.now(); // Start a transaction on the existing PgListener connection let txn = self @@ -505,25 +506,25 @@ impl QueueWorker { if now - self.last_heartbeat >= chrono::Duration::minutes(1) { tracing::info!("Sending heartbeat"); repo.queue_worker() - .heartbeat(&self.clock, &self.registration) + .heartbeat(clock, &self.registration) .await?; self.last_heartbeat = now; } // Remove any dead worker leader leases repo.queue_worker() - .remove_leader_lease_if_expired(&self.clock) + .remove_leader_lease_if_expired(clock) .await?; // Try to become (or stay) the leader let leader = repo .queue_worker() - .try_get_leader_lease(&self.clock, &self.registration) + .try_get_leader_lease(clock, &self.registration) .await?; // Process any job task which finished self.tracker - .process_jobs(&mut self.rng, &self.clock, &mut repo, false) + .process_jobs(&mut rng, clock, &mut repo, false) .await?; // Compute how many jobs we should fetch at most @@ -538,7 +539,7 @@ impl QueueWorker { let queues = self.tracker.queues(); let jobs = repo .queue_job() - .reserve(&self.clock, &self.registration, &queues, max_jobs_to_fetch) + .reserve(clock, &self.registration, &queues, max_jobs_to_fetch) .await?; for Job { @@ -592,6 +593,9 @@ impl QueueWorker { return Err(QueueRunnerError::NotLeader); } + let clock = self.state.clock(); + let mut rng = self.state.rng(); + // Start a transaction on the existing PgListener connection let txn = self .listener @@ -633,10 +637,10 @@ impl QueueWorker { // Look at the state of schedules in the database let schedules_status = repo.queue_schedule().list().await?; - let now = self.clock.now(); + let now = clock.now(); for schedule in &self.schedules { // Find the schedule status from the database - let Some(schedule_status) = schedules_status + let Some(status) = schedules_status .iter() .find(|s| s.schedule_name == schedule.schedule_name) else { @@ -648,13 +652,13 @@ impl QueueWorker { }; // Figure out if we should schedule a new job - if let Some(next_time) = schedule_status.last_scheduled_at { + if let Some(next_time) = status.last_scheduled_at { if next_time > now { // We already have a job scheduled in the future, skip continue; } - if schedule_status.last_scheduled_job_completed == Some(false) { + if status.last_scheduled_job_completed == Some(false) { // The last scheduled job has not completed yet, skip continue; } @@ -670,8 +674,8 @@ impl QueueWorker { repo.queue_job() .schedule_later( - &mut self.rng, - &self.clock, + &mut rng, + clock, schedule.queue_name, schedule.payload.clone(), serde_json::json!({}), @@ -684,16 +688,13 @@ impl QueueWorker { // We also check if the worker is dead, and if so, we shutdown all the dead // workers that haven't checked in the last two minutes repo.queue_worker() - .shutdown_dead_workers(&self.clock, Duration::minutes(2)) + .shutdown_dead_workers(clock, Duration::minutes(2)) .await?; // TODO: mark tasks those workers had as lost // Mark all the scheduled jobs as available - let scheduled = repo - .queue_job() - .schedule_available_jobs(&self.clock) - .await?; + let scheduled = repo.queue_job().schedule_available_jobs(clock).await?; match scheduled { 0 => {} 1 => tracing::info!("One scheduled job marked as available"), @@ -713,6 +714,73 @@ impl QueueWorker { Ok(()) } + + /// Process all the pending jobs in the queue. + /// This should only be called in tests! + /// + /// # Errors + /// + /// This function can fail if the database connection fails. + pub async fn process_all_jobs_in_tests(&mut self) -> Result<(), QueueRunnerError> { + // I swear, I'm the leader! + self.am_i_leader = true; + + // First, perform the leader duties. This will make sure that we schedule + // recurring jobs. + self.perform_leader_duties().await?; + + let clock = self.state.clock(); + let mut rng = self.state.rng(); + + // Grab the connection from the PgListener + let txn = self + .listener + .begin() + .await + .map_err(QueueRunnerError::StartTransaction)?; + let mut repo = PgRepository::from_conn(txn); + + // Spawn all the jobs in the database + let queues = self.tracker.queues(); + let jobs = repo + .queue_job() + // I really hope that we don't spawn more than 10k jobs in tests + .reserve(clock, &self.registration, &queues, 10_000) + .await?; + + for Job { + id, + queue_name, + payload, + metadata, + attempt, + } in jobs + { + let cancellation_token = self.cancellation_token.child_token(); + let start = Instant::now(); + let context = JobContext { + id, + metadata, + queue_name, + attempt, + start, + cancellation_token, + }; + + self.tracker.spawn_job(self.state.clone(), context, payload); + } + + self.tracker + .process_jobs(&mut rng, clock, &mut repo, true) + .await?; + + repo.into_inner() + .commit() + .await + .map_err(QueueRunnerError::CommitTransaction)?; + + Ok(()) + } } /// Tracks running jobs @@ -893,7 +961,6 @@ impl JobTracker { /// If `blocking` is `true`, this function will block until all the jobs /// are finished. Otherwise, it will return as soon as it processed the /// already finished jobs. - #[allow(clippy::too_many_lines)] async fn process_jobs( &mut self, rng: &mut (dyn RngCore + Send), diff --git a/crates/tasks/src/recovery.rs b/crates/tasks/src/recovery.rs index 9d68dad66..51afcc295 100644 --- a/crates/tasks/src/recovery.rs +++ b/crates/tasks/src/recovery.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context; use async_trait::async_trait; @@ -70,26 +70,18 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { .await .map_err(JobError::retry)?; - for email in page.edges { + for edge in page.edges { let ticket = Alphanumeric.sample_string(&mut rng, 32); let ticket = repo .user_recovery() - .add_ticket(&mut rng, &clock, &session, &email, ticket) + .add_ticket(&mut rng, clock, &session, &edge.node, ticket) .await .map_err(JobError::retry)?; - let user_email = repo - .user_email() - .lookup(email.id) - .await - .map_err(JobError::retry)? - .context("User email not found") - .map_err(JobError::fail)?; - let user = repo .user() - .lookup(user_email.user_id) + .lookup(edge.node.user_id) .await .map_err(JobError::retry)? .context("User not found") @@ -97,7 +89,7 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { let url = url_builder.account_recovery_link(ticket.ticket); - let address: Address = user_email.email.parse().map_err(JobError::fail)?; + let address: Address = edge.node.email.parse().map_err(JobError::fail)?; let mailbox = Mailbox::new(Some(user.username.clone()), address); info!("Sending recovery email to {}", mailbox); @@ -112,7 +104,7 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { ); } - cursor = cursor.after(email.id); + cursor = cursor.after(edge.cursor); } if !page.has_next_page { diff --git a/crates/tasks/src/sessions.rs b/crates/tasks/src/sessions.rs index fc7f89796..eede69d51 100644 --- a/crates/tasks/src/sessions.rs +++ b/crates/tasks/src/sessions.rs @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::collections::HashSet; @@ -39,7 +39,7 @@ impl RunnableJob for ExpireInactiveSessionsJob { repo.queue_job() .schedule_job( &mut rng, - &clock, + clock, ExpireInactiveOAuthSessionsJob::new(now - ttl), ) .await @@ -50,7 +50,7 @@ impl RunnableJob for ExpireInactiveSessionsJob { repo.queue_job() .schedule_job( &mut rng, - &clock, + clock, ExpireInactiveCompatSessionsJob::new(now - ttl), ) .await @@ -61,7 +61,7 @@ impl RunnableJob for ExpireInactiveSessionsJob { repo.queue_job() .schedule_job( &mut rng, - &clock, + clock, ExpireInactiveUserSessionsJob::new(now - ttl), ) .await @@ -104,20 +104,20 @@ impl RunnableJob for ExpireInactiveOAuthSessionsJob { if let Some(job) = self.next(&page) { tracing::info!("Scheduling job to expire the next batch of inactive sessions"); repo.queue_job() - .schedule_job(&mut rng, &clock, job) + .schedule_job(&mut rng, clock, job) .await .map_err(JobError::retry)?; } for edge in page.edges { - if let Some(user_id) = edge.user_id { + if let Some(user_id) = edge.node.user_id { let inserted = users_synced.insert(user_id); if inserted { tracing::info!(user.id = %user_id, "Scheduling devices sync for user"); repo.queue_job() .schedule_job_later( &mut rng, - &clock, + clock, SyncDevicesJob::new_for_id(user_id), clock.now() + delay, ) @@ -128,7 +128,7 @@ impl RunnableJob for ExpireInactiveOAuthSessionsJob { } repo.oauth2_session() - .finish(&clock, edge) + .finish(clock, edge.node) .await .map_err(JobError::retry)?; } @@ -168,20 +168,20 @@ impl RunnableJob for ExpireInactiveCompatSessionsJob { if let Some(job) = self.next(&page) { tracing::info!("Scheduling job to expire the next batch of inactive sessions"); repo.queue_job() - .schedule_job(&mut rng, &clock, job) + .schedule_job(&mut rng, clock, job) .await .map_err(JobError::retry)?; } for edge in page.edges { - let inserted = users_synced.insert(edge.user_id); + let inserted = users_synced.insert(edge.node.user_id); if inserted { - tracing::info!(user.id = %edge.user_id, "Scheduling devices sync for user"); + tracing::info!(user.id = %edge.node.user_id, "Scheduling devices sync for user"); repo.queue_job() .schedule_job_later( &mut rng, - &clock, - SyncDevicesJob::new_for_id(edge.user_id), + clock, + SyncDevicesJob::new_for_id(edge.node.user_id), clock.now() + delay, ) .await @@ -190,7 +190,7 @@ impl RunnableJob for ExpireInactiveCompatSessionsJob { } repo.compat_session() - .finish(&clock, edge) + .finish(clock, edge.node) .await .map_err(JobError::retry)?; } @@ -223,14 +223,14 @@ impl RunnableJob for ExpireInactiveUserSessionsJob { if let Some(job) = self.next(&page) { tracing::info!("Scheduling job to expire the next batch of inactive sessions"); repo.queue_job() - .schedule_job(&mut rng, &clock, job) + .schedule_job(&mut rng, clock, job) .await .map_err(JobError::retry)?; } for edge in page.edges { repo.browser_session() - .finish(&clock, edge) + .finish(clock, edge.node) .await .map_err(JobError::retry)?; } diff --git a/crates/tasks/src/user.rs b/crates/tasks/src/user.rs index 4dfa081c4..e605670cc 100644 --- a/crates/tasks/src/user.rs +++ b/crates/tasks/src/user.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use anyhow::Context; use async_trait::async_trait; @@ -10,6 +10,7 @@ use mas_storage::{ RepositoryAccess, compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, + personal::PersonalSessionFilter, queue::{DeactivateUserJob, ReactivateUserJob}, user::{BrowserSessionFilter, UserEmailFilter, UserRepository}, }; @@ -41,17 +42,10 @@ impl RunnableJob for DeactivateUserJob { .context("User not found") .map_err(JobError::fail)?; - // Let's first lock & deactivate the user + // Let's first deactivate the user let user = repo .user() - .lock(&clock, user) - .await - .context("Failed to lock user") - .map_err(JobError::retry)?; - - let user = repo - .user() - .deactivate(&clock, user) + .deactivate(clock, user) .await .context("Failed to deactivate user") .map_err(JobError::retry)?; @@ -60,7 +54,7 @@ impl RunnableJob for DeactivateUserJob { let n = repo .browser_session() .finish_bulk( - &clock, + clock, BrowserSessionFilter::new().for_user(&user).active_only(), ) .await @@ -70,7 +64,7 @@ impl RunnableJob for DeactivateUserJob { let n = repo .oauth2_session() .finish_bulk( - &clock, + clock, OAuth2SessionFilter::new().for_user(&user).active_only(), ) .await @@ -80,13 +74,43 @@ impl RunnableJob for DeactivateUserJob { let n = repo .compat_session() .finish_bulk( - &clock, + clock, CompatSessionFilter::new().for_user(&user).active_only(), ) .await .map_err(JobError::retry)?; info!(affected = n, "Killed all compatibility sessions for user"); + let n = repo + .personal_session() + .revoke_bulk( + clock, + PersonalSessionFilter::new() + .for_actor_user(&user) + .active_only(), + ) + .await + .map_err(JobError::retry)?; + info!( + affected = n, + "Killed all compatibility sessions acting as user" + ); + + let n = repo + .personal_session() + .revoke_bulk( + clock, + PersonalSessionFilter::new() + .for_owner_user(&user) + .active_only(), + ) + .await + .map_err(JobError::retry)?; + info!( + affected = n, + "Killed all compatibility sessions owned by user" + ); + // Delete all the email addresses for the user let n = repo .user_email() @@ -99,10 +123,9 @@ impl RunnableJob for DeactivateUserJob { // we want the user to be locked out as soon as possible repo.save().await.map_err(JobError::retry)?; - let mxid = matrix.mxid(&user.username); - info!("Deactivating user {} on homeserver", mxid); + info!("Deactivating user {} on homeserver", user.username); matrix - .delete_user(&mxid, self.hs_erase()) + .delete_user(&user.username, self.hs_erase()) .await .map_err(JobError::retry)?; @@ -130,16 +153,19 @@ impl RunnableJob for ReactivateUserJob { .context("User not found") .map_err(JobError::fail)?; - let mxid = matrix.mxid(&user.username); - info!("Reactivating user {} on homeserver", mxid); + info!("Reactivating user {} on homeserver", user.username); matrix - .reactivate_user(&mxid) + .reactivate_user(&user.username) .await .map_err(JobError::retry)?; - // We want to unlock the user from our side only once it has been reactivated on - // the homeserver - let _user = repo.user().unlock(user).await.map_err(JobError::retry)?; + // We want to reactivate the user from our side only once it has been + // reactivated on the homeserver + let _user = repo + .user() + .reactivate(user) + .await + .map_err(JobError::retry)?; repo.save().await.map_err(JobError::retry)?; Ok(()) diff --git a/crates/templates/Cargo.toml b/crates/templates/Cargo.toml index a99f5ae56..ffe2dbf74 100644 --- a/crates/templates/Cargo.toml +++ b/crates/templates/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-templates" version.workspace = true diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index c21096ee6..597683a03 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. //! Contexts used in templates @@ -12,6 +12,7 @@ mod ext; mod features; use std::{ + collections::BTreeMap, fmt::Formatter, net::{IpAddr, Ipv4Addr}, }; @@ -21,9 +22,9 @@ use http::{Method, Uri, Version}; use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, - UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication, - UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User, + UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -105,21 +106,53 @@ pub trait TemplateContext: Serialize { /// /// This is then used to check for template validity in unit tests and in /// the CLI (`cargo run -- templates check`) - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized; } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SampleIdentifier { + pub components: Vec<(&'static str, String)>, +} + +impl SampleIdentifier { + pub fn from_index(index: usize) -> Self { + Self { + components: Vec::default(), + } + .with_appended("index", format!("{index}")) + } + + pub fn with_appended(&self, kind: &'static str, locale: String) -> Self { + let mut new = self.clone(); + new.components.push((kind, locale)); + new + } +} + +pub(crate) fn sample_list(samples: Vec) -> BTreeMap { + samples + .into_iter() + .enumerate() + .map(|(index, sample)| (SampleIdentifier::from_index(index), sample)) + .collect() +} + impl TemplateContext for () { fn sample( _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - Vec::new() + BTreeMap::new() } } @@ -148,7 +181,11 @@ impl std::ops::Deref for WithLanguage { } impl TemplateContext for WithLanguage { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -157,9 +194,14 @@ impl TemplateContext for WithLanguage { .flat_map(|locale| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithLanguage { - lang: locale.to_string(), - inner, + .map(|(sample_id, sample)| { + ( + sample_id.with_appended("locale", locale.to_string()), + WithLanguage { + lang: locale.to_string(), + inner: sample, + }, + ) }) }) .collect() @@ -176,15 +218,24 @@ pub struct WithCsrf { } impl TemplateContext for WithCsrf { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { T::sample(now, rng, locales) .into_iter() - .map(|inner| WithCsrf { - csrf_token: "fake_csrf_token".into(), - inner, + .map(|(k, inner)| { + ( + k, + WithCsrf { + csrf_token: "fake_csrf_token".into(), + inner, + }, + ) }) .collect() } @@ -200,18 +251,28 @@ pub struct WithSession { } impl TemplateContext for WithSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { BrowserSession::samples(now, rng) .into_iter() - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + k.with_appended("browser-session", session_index.to_string()), + WithSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -228,7 +289,11 @@ pub struct WithOptionalSession { } impl TemplateContext for WithOptionalSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -236,12 +301,22 @@ impl TemplateContext for WithOptionalSession { .into_iter() .map(Some) // Wrap all samples in an Option .chain(std::iter::once(None)) // Add the "None" option - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithOptionalSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + if session.is_some() { + k.with_appended("browser-session", session_index.to_string()) + } else { + k + }, + WithOptionalSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -269,11 +344,11 @@ impl TemplateContext for EmptyContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![EmptyContext] + sample_list(vec![EmptyContext]) } } @@ -297,15 +372,15 @@ impl TemplateContext for IndexContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { discovery_url: "https://example.com/.well-known/openid-configuration" .parse() .unwrap(), - }] + }]) } } @@ -343,12 +418,12 @@ impl TemplateContext for AppContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -376,12 +451,12 @@ impl TemplateContext for ApiDocContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -468,12 +543,12 @@ impl TemplateContext for LoginContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![ + sample_list(vec![ LoginContext { form: FormState::default(), next: None, @@ -503,7 +578,7 @@ impl TemplateContext for LoginContext { next: None, providers: Vec::new(), }, - ] + ]) } } @@ -576,14 +651,14 @@ impl TemplateContext for RegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![RegisterContext { + sample_list(vec![RegisterContext { providers: Vec::new(), next: None, - }] + }]) } } @@ -619,15 +694,15 @@ impl TemplateContext for PasswordRegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![PasswordRegisterContext { + sample_list(vec![PasswordRegisterContext { form: FormState::default(), next: None, - }] + }]) } } @@ -657,24 +732,30 @@ pub struct ConsentContext { } impl TemplateContext for ConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - let action = PostAuthAction::continue_grant(grant.id); - // XXX - grant.client_id = client.id; - Self { - grant, - client, - action, - } - }) - .collect() + sample_list( + Client::samples(now, rng) + .into_iter() + .map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + let action = PostAuthAction::continue_grant(grant.id); + // XXX + grant.client_id = client.id; + Self { + grant, + client, + action, + } + }) + .collect(), + ) } } @@ -709,38 +790,44 @@ pub struct PolicyViolationContext { } impl TemplateContext for PolicyViolationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .flat_map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - // XXX - grant.client_id = client.id; + sample_list( + Client::samples(now, rng) + .into_iter() + .flat_map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + // XXX + grant.client_id = client.id; - let authorization_grant = - PolicyViolationContext::for_authorization_grant(grant, client.clone()); - let device_code_grant = PolicyViolationContext::for_device_code_grant( - DeviceCodeGrant { - id: Ulid::from_datetime_with_source(now.into(), rng), - state: mas_data_model::DeviceCodeGrantState::Pending, - client_id: client.id, - scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), - device_code: Alphanumeric.sample_string(rng, 32), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - ip_address: None, - user_agent: None, - }, - client, - ); + let authorization_grant = + PolicyViolationContext::for_authorization_grant(grant, client.clone()); + let device_code_grant = PolicyViolationContext::for_device_code_grant( + DeviceCodeGrant { + id: Ulid::from_datetime_with_source(now.into(), rng), + state: mas_data_model::DeviceCodeGrantState::Pending, + client_id: client.id, + scope: [OPENID].into_iter().collect(), + user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + device_code: Alphanumeric.sample_string(rng, 32), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + ip_address: None, + user_agent: None, + }, + client, + ); - [authorization_grant, device_code_grant] - }) - .collect() + [authorization_grant, device_code_grant] + }) + .collect(), + ) } } @@ -778,18 +865,22 @@ pub struct CompatSsoContext { } impl TemplateContext for CompatSsoContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![CompatSsoContext::new(CompatSsoLogin { + sample_list(vec![CompatSsoContext::new(CompatSsoLogin { id, redirect_uri: Url::parse("https://app.element.io/").unwrap(), login_token: "abcdefghijklmnopqrstuvwxyz012345".into(), created_at: now, state: CompatSsoLoginState::Pending, - })] + })]) } } @@ -836,11 +927,15 @@ impl EmailRecoveryContext { } impl TemplateContext for EmailRecoveryContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng).into_iter().map(|user| { + sample_list(User::samples(now, rng).into_iter().map(|user| { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "hello@example.com".to_owned(), @@ -854,7 +949,7 @@ impl TemplateContext for EmailRecoveryContext { let link = "https://example.com/recovery/complete?ticket=abcdefghijklmnopqrstuvwxyz0123456789".parse().unwrap(); Self::new(user, session, link) - }).collect() + }).collect()) } } @@ -897,28 +992,37 @@ impl EmailVerificationContext { } impl TemplateContext for EmailVerificationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - BrowserSession::samples(now, rng) - .into_iter() - .map(|browser_session| { - let authentication_code = UserEmailAuthenticationCode { - id: Ulid::from_datetime_with_source(now.into(), rng), - user_email_authentication_id: Ulid::from_datetime_with_source(now.into(), rng), - code: "123456".to_owned(), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - }; + sample_list( + BrowserSession::samples(now, rng) + .into_iter() + .map(|browser_session| { + let authentication_code = UserEmailAuthenticationCode { + id: Ulid::from_datetime_with_source(now.into(), rng), + user_email_authentication_id: Ulid::from_datetime_with_source( + now.into(), + rng, + ), + code: "123456".to_owned(), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + }; - Self { - browser_session: Some(browser_session), - user_registration: None, - authentication_code, - } - }) - .collect() + Self { + browser_session: Some(browser_session), + user_registration: None, + authentication_code, + } + }) + .collect(), + ) } } @@ -963,7 +1067,11 @@ impl RegisterStepsVerifyEmailContext { } impl TemplateContext for RegisterStepsVerifyEmailContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -976,10 +1084,10 @@ impl TemplateContext for RegisterStepsVerifyEmailContext { completed_at: None, }; - vec![Self { + sample_list(vec![Self { form: FormState::default(), authentication, - }] + }]) } } @@ -1003,13 +1111,13 @@ impl TemplateContext for RegisterStepsEmailInUseContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let email = "hello@example.com".to_owned(); let action = PostAuthAction::continue_grant(Ulid::nil()); - vec![Self::new(email, Some(action))] + sample_list(vec![Self::new(email, Some(action))]) } } @@ -1058,13 +1166,13 @@ impl TemplateContext for RegisterStepsDisplayNameContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1113,13 +1221,13 @@ impl TemplateContext for RegisterStepsRegistrationTokenContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1164,11 +1272,11 @@ impl TemplateContext for RecoveryStartContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() @@ -1178,7 +1286,7 @@ impl TemplateContext for RecoveryStartContext { FormState::default() .with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid), ), - ] + ]) } } @@ -1202,7 +1310,11 @@ impl RecoveryProgressContext { } impl TemplateContext for RecoveryProgressContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1216,7 +1328,7 @@ impl TemplateContext for RecoveryProgressContext { consumed_at: None, }; - vec![ + sample_list(vec![ Self { session: session.clone(), resend_failed_due_to_rate_limit: false, @@ -1225,7 +1337,7 @@ impl TemplateContext for RecoveryProgressContext { session, resend_failed_due_to_rate_limit: true, }, - ] + ]) } } @@ -1244,7 +1356,11 @@ impl RecoveryExpiredContext { } impl TemplateContext for RecoveryExpiredContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1258,10 +1374,9 @@ impl TemplateContext for RecoveryExpiredContext { consumed_at: None, }; - vec![Self { session }] + sample_list(vec![Self { session }]) } } - /// Fields of the account recovery finish form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -1305,34 +1420,40 @@ impl RecoveryFinishContext { } impl TemplateContext for RecoveryFinishContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .flat_map(|user| { - vec![ - Self::new(user.clone()), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPassword, - FieldError::Invalid, + sample_list( + User::samples(now, rng) + .into_iter() + .flat_map(|user| { + vec![ + Self::new(user.clone()), + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Invalid, + ), ), - ), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPasswordConfirm, - FieldError::Invalid, + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::Invalid, + ), ), - ), - ] - }) - .collect() + ] + }) + .collect(), + ) } } -/// Context used by the `pages/upstream_oauth2/{link_mismatch,do_login}.html` +/// Context used by the `pages/upstream_oauth2/{link_mismatch,login_link}.html` /// templates #[derive(Serialize)] pub struct UpstreamExistingLinkContext { @@ -1348,14 +1469,20 @@ impl UpstreamExistingLinkContext { } impl TemplateContext for UpstreamExistingLinkContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|linked_user| Self { linked_user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|linked_user| Self { linked_user }) + .collect(), + ) } } @@ -1380,12 +1507,16 @@ impl UpstreamSuggestLink { } impl TemplateContext for UpstreamSuggestLink { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![Self::for_link_id(id)] + sample_list(vec![Self::for_link_id(id)]) } } @@ -1505,11 +1636,15 @@ impl UpstreamRegister { } impl TemplateContext for UpstreamRegister { - fn sample(now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![Self::new( + sample_list(vec![Self::new( UpstreamOAuthLink { id: Ulid::nil(), provider_id: Ulid::nil(), @@ -1543,8 +1678,9 @@ impl TemplateContext for UpstreamRegister { forward_login_hint: false, created_at: now, disabled_at: None, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, - )] + )]) } } @@ -1590,17 +1726,17 @@ impl TemplateContext for DeviceLinkContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() .with_error_on_field(DeviceLinkFormField::Code, FieldError::Required), ), - ] + ]) } } @@ -1620,13 +1756,17 @@ impl DeviceConsentContext { } impl TemplateContext for DeviceConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() - .map(|client| { + .map(|client| { let grant = DeviceCodeGrant { id: Ulid::from_datetime_with_source(now.into(), rng), state: mas_data_model::DeviceCodeGrantState::Pending, @@ -1636,12 +1776,12 @@ impl TemplateContext for DeviceConsentContext { device_code: Alphanumeric.sample_string(rng, 32), created_at: now - Duration::try_minutes(5).unwrap(), expires_at: now + Duration::try_minutes(25).unwrap(), - ip_address: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), + ip_address: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), user_agent: Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned()), }; Self { grant, client } }) - .collect() + .collect()) } } @@ -1661,14 +1801,20 @@ impl AccountInactiveContext { } impl TemplateContext for AccountInactiveContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|user| AccountInactiveContext { user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|user| AccountInactiveContext { user }) + .collect(), + ) } } @@ -1691,17 +1837,21 @@ impl DeviceNameContext { } impl TemplateContext for DeviceNameContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() .map(|client| DeviceNameContext { client, raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(), }) - .collect() + .collect()) } } @@ -1713,16 +1863,25 @@ pub struct FormPostContext { } impl TemplateContext for FormPostContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let sample_params = T::sample(now, rng, locales); sample_params .into_iter() - .map(|params| FormPostContext { - redirect_uri: "https://example.com/callback".parse().ok(), - params, + .map(|(k, params)| { + ( + k, + FormPostContext { + redirect_uri: "https://example.com/callback".parse().ok(), + params, + }, + ) }) .collect() } @@ -1790,18 +1949,18 @@ impl TemplateContext for ErrorContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new() .with_code("sample_error") .with_description("A fancy description".into()) .with_details("Something happened".into()), Self::new().with_code("another_error"), Self::new(), - ] + ]) } } @@ -1880,11 +2039,15 @@ impl NotFoundContext { } impl TemplateContext for NotFoundContext { - fn sample(_now: DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + _now: DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(&Method::GET, Version::HTTP_11, &"/".parse().unwrap()), Self::new(&Method::POST, Version::HTTP_2, &"/foo/bar".parse().unwrap()), Self::new( @@ -1892,6 +2055,6 @@ impl TemplateContext for NotFoundContext { Version::HTTP_10, &"/foo?bar=baz".parse().unwrap(), ), - ] + ]) } } diff --git a/crates/templates/src/context/branding.rs b/crates/templates/src/context/branding.rs index d9a451c4d..15932567f 100644 --- a/crates/templates/src/context/branding.rs +++ b/crates/templates/src/context/branding.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; @@ -58,9 +58,9 @@ impl Object for SiteBranding { fn get_value(self: &Arc, name: &Value) -> Option { match name.as_str()? { "server_name" => Some(self.server_name.clone().into()), - "policy_uri" => self.policy_uri.clone().map(Value::from), - "tos_uri" => self.tos_uri.clone().map(Value::from), - "imprint" => self.imprint.clone().map(Value::from), + "policy_uri" => Some(Value::from(self.policy_uri.clone())), + "tos_uri" => Some(Value::from(self.tos_uri.clone())), + "imprint" => Some(Value::from(self.imprint.clone())), _ => None, } } diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index 38d723ca0..3daafb745 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -1,10 +1,10 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc}; use mas_i18n::DataLocale; use minijinja::{ @@ -13,7 +13,7 @@ use minijinja::{ }; use serde::Serialize; -use crate::TemplateContext; +use crate::{TemplateContext, context::SampleIdentifier}; #[derive(Debug)] struct CaptchaConfig(mas_data_model::CaptchaConfig); @@ -62,14 +62,13 @@ impl TemplateContext for WithCaptcha { now: chrono::DateTime, rng: &mut impl rand::prelude::Rng, locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - let inner = T::sample(now, rng, locales); - inner + T::sample(now, rng, locales) .into_iter() - .map(|inner| Self::new(None, inner)) + .map(|(k, inner)| (k, Self::new(None, inner))) .collect() } } diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index 5a94430a7..679ad91a7 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use mas_data_model::SiteConfig; @@ -45,6 +45,7 @@ impl SiteConfigExt for SiteConfig { fn templates_features(&self) -> SiteFeatures { SiteFeatures { password_registration: self.password_registration_enabled, + password_registration_email_required: self.password_registration_email_required, password_login: self.password_login_enabled, account_recovery: self.account_recovery_allowed, login_with_email_allowed: self.login_with_email_allowed, diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index a493e0cee..07e80f702 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::sync::Arc; @@ -18,6 +18,9 @@ pub struct SiteFeatures { /// Whether local password-based registration is enabled. pub password_registration: bool, + /// Whether local password-based registration requires an email address. + pub password_registration_email_required: bool, + /// Whether local password-based login is enabled. pub password_login: bool, @@ -32,6 +35,9 @@ impl Object for SiteFeatures { fn get_value(self: &Arc, field: &Value) -> Option { match field.as_str()? { "password_registration" => Some(Value::from(self.password_registration)), + "password_registration_email_required" => { + Some(Value::from(self.password_registration_email_required)) + } "password_login" => Some(Value::from(self.password_login)), "account_recovery" => Some(Value::from(self.account_recovery)), "login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)), @@ -42,6 +48,7 @@ impl Object for SiteFeatures { fn enumerate(self: &Arc) -> Enumerator { Enumerator::Str(&[ "password_registration", + "password_registration_email_required", "password_login", "account_recovery", "login_with_email_allowed", diff --git a/crates/templates/src/forms.rs b/crates/templates/src/forms.rs index 2b769e122..94e20be3a 100644 --- a/crates/templates/src/forms.rs +++ b/crates/templates/src/forms.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::{collections::HashMap, hash::Hash}; diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 3229cde28..631e4742e 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // This is needed to make the Environment::add* functions work #![allow(clippy::needless_pass_by_value)] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 88a72225a..603dcfdf6 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -1,15 +1,18 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![deny(missing_docs)] #![allow(clippy::module_name_repetitions)] //! Templates rendering -use std::{collections::HashSet, sync::Arc}; +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; use anyhow::Context as _; use arc_swap::ArcSwap; @@ -17,7 +20,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use mas_i18n::Translator; use mas_router::UrlBuilder; use mas_spa::ViteManifest; -use minijinja::Value; +use minijinja::{UndefinedBehavior, Value}; use rand::Rng; use serde::Serialize; use thiserror::Error; @@ -50,6 +53,7 @@ pub use self::{ }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; +use crate::context::SampleIdentifier; /// Escape the given string for use in HTML /// @@ -71,6 +75,9 @@ pub struct Templates { vite_manifest_path: Utf8PathBuf, translations_path: Utf8PathBuf, path: Utf8PathBuf, + /// Whether template rendering is in strict mode (for testing, + /// until this can be rolled out in production.) + strict: bool, } /// There was an issue while loading the templates @@ -135,6 +142,10 @@ fn is_hidden(entry: &DirEntry) -> bool { impl Templates { /// Load the templates from the given config + /// + /// # Errors + /// + /// Returns an error if the templates could not be loaded from disk. #[tracing::instrument( name = "templates.load", skip_all, @@ -147,6 +158,7 @@ impl Templates { translations_path: Utf8PathBuf, branding: SiteBranding, features: SiteFeatures, + strict: bool, ) -> Result { let (translator, environment) = Self::load_( &path, @@ -155,6 +167,7 @@ impl Templates { &translations_path, branding.clone(), features, + strict, ) .await?; Ok(Self { @@ -166,6 +179,7 @@ impl Templates { translations_path, branding, features, + strict, }) } @@ -176,6 +190,7 @@ impl Templates { translations_path: &Utf8Path, branding: SiteBranding, features: SiteFeatures, + strict: bool, ) -> Result<(Arc, Arc>), TemplateLoadingError> { let path = path.to_owned(); let span = tracing::Span::current(); @@ -201,6 +216,15 @@ impl Templates { span.in_scope(move || { let mut loaded: HashSet<_> = HashSet::new(); let mut env = minijinja::Environment::new(); + // Don't allow use of undefined variables + env.set_undefined_behavior(if strict { + UndefinedBehavior::Strict + } else { + // For now, allow semi-strict, because we don't have total test coverage of + // tests and some tests rely on if conditions against sometimes-undefined + // variables + UndefinedBehavior::SemiStrict + }); let root = path.canonicalize_utf8()?; info!(%root, "Loading templates from filesystem"); for entry in walkdir::WalkDir::new(&root) @@ -254,6 +278,10 @@ impl Templates { } /// Reload the templates on disk + /// + /// # Errors + /// + /// Returns an error if the templates could not be reloaded from disk. #[tracing::instrument( name = "templates.reload", skip_all, @@ -267,6 +295,7 @@ impl Templates { &self.translations_path, self.branding.clone(), self.features, + self.strict, ) .await?; @@ -320,7 +349,7 @@ register_templates! { /// Render the Swagger API reference pub fn render_swagger(ApiDocContext) { "swagger/doc.html" } - /// Render the Swagger OAuth2 callback page + /// Render the Swagger OAuth callback page pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" } /// Render the login page @@ -374,8 +403,8 @@ register_templates! { /// Render the account recovery disabled page pub fn render_recovery_disabled(WithLanguage) { "pages/recovery/disabled.html" } - /// Render the form used by the form_post response mode - pub fn render_form_post(WithLanguage>) { "form_post.html" } + /// Render the form used by the `form_post` response mode + pub fn render_form_post<#[sample(EmptyContext)] T: Serialize>(WithLanguage>) { "form_post.html" } /// Render the HTML error page pub fn render_error(ErrorContext) { "pages/error.html" } @@ -401,6 +430,9 @@ register_templates! { /// Render the upstream link mismatch message pub fn render_upstream_oauth2_link_mismatch(WithLanguage>>) { "pages/upstream_oauth2/link_mismatch.html" } + /// Render the upstream link match + pub fn render_upstream_oauth2_login_link(WithLanguage>) { "pages/upstream_oauth2/login_link.html" } + /// Render the upstream suggest link message pub fn render_upstream_oauth2_suggest_link(WithLanguage>>) { "pages/upstream_oauth2/suggest_link.html" } @@ -428,7 +460,13 @@ register_templates! { impl Templates { /// Render all templates with the generated samples to check if they render - /// properly + /// properly. + /// + /// Returns the renders in a map whose keys are template names + /// and the values are lists of renders (according to the list + /// of samples). + /// Samples are stable across re-runs and can be used for + /// acceptance testing. /// /// # Errors /// @@ -437,46 +475,8 @@ impl Templates { &self, now: chrono::DateTime, rng: &mut impl Rng, - ) -> anyhow::Result<()> { - check::render_not_found(self, now, rng)?; - check::render_app(self, now, rng)?; - check::render_swagger(self, now, rng)?; - check::render_swagger_callback(self, now, rng)?; - check::render_login(self, now, rng)?; - check::render_register(self, now, rng)?; - check::render_password_register(self, now, rng)?; - check::render_register_steps_verify_email(self, now, rng)?; - check::render_register_steps_email_in_use(self, now, rng)?; - check::render_register_steps_display_name(self, now, rng)?; - check::render_register_steps_registration_token(self, now, rng)?; - check::render_consent(self, now, rng)?; - check::render_policy_violation(self, now, rng)?; - check::render_sso_login(self, now, rng)?; - check::render_index(self, now, rng)?; - check::render_recovery_start(self, now, rng)?; - check::render_recovery_progress(self, now, rng)?; - check::render_recovery_finish(self, now, rng)?; - check::render_recovery_expired(self, now, rng)?; - check::render_recovery_consumed(self, now, rng)?; - check::render_recovery_disabled(self, now, rng)?; - check::render_form_post::(self, now, rng)?; - check::render_error(self, now, rng)?; - check::render_email_recovery_txt(self, now, rng)?; - check::render_email_recovery_html(self, now, rng)?; - check::render_email_recovery_subject(self, now, rng)?; - check::render_email_verification_txt(self, now, rng)?; - check::render_email_verification_html(self, now, rng)?; - check::render_email_verification_subject(self, now, rng)?; - check::render_upstream_oauth2_link_mismatch(self, now, rng)?; - check::render_upstream_oauth2_suggest_link(self, now, rng)?; - check::render_upstream_oauth2_do_register(self, now, rng)?; - check::render_device_link(self, now, rng)?; - check::render_device_consent(self, now, rng)?; - check::render_account_deactivated(self, now, rng)?; - check::render_account_locked(self, now, rng)?; - check::render_account_logged_out(self, now, rng)?; - check::render_device_name(self, now, rng)?; - Ok(()) + ) -> anyhow::Result> { + check::all(self, now, rng) } } @@ -497,6 +497,7 @@ mod tests { let features = SiteFeatures { password_login: true, password_registration: true, + password_registration_email_required: true, account_recovery: true, login_with_email_allowed: true, }; @@ -511,6 +512,8 @@ mod tests { translations_path, branding, features, + // Use strict mode in tests + true, ) .await .unwrap(); diff --git a/crates/templates/src/macros.rs b/crates/templates/src/macros.rs index a3166b2bb..95b57f0d9 100644 --- a/crates/templates/src/macros.rs +++ b/crates/templates/src/macros.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. /// Count the number of tokens. Used to have a fixed-sized array for the /// templates list. @@ -31,7 +31,9 @@ macro_rules! register_templates { pub fn $name:ident // Optional list of generics. Taken from // https://newbedev.com/rust-macro-accepting-type-with-generic-parameters - $(< $( $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+ >)? + // For sample rendering, we also require a 'sample' generic parameter to be provided, + // using #[sample(Type)] attribute syntax + $(< $( #[sample( $generic_default:tt )] $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+ >)? // Type of context taken by the template ( $param:ty ) { @@ -69,28 +71,53 @@ macro_rules! register_templates { pub mod check { use super::*; + /// Check and render all templates with all samples. + /// + /// Returns the sample renders. The keys in the map are the template names. + /// + /// # Errors + /// + /// Returns an error if any template fails to render with any of the sample. + pub(crate) fn all(templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) -> anyhow::Result<::std::collections::BTreeMap<(&'static str, SampleIdentifier), String>> { + let mut out = ::std::collections::BTreeMap::new(); + // TODO shouldn't the Rng be independent for each render? + $( + out.extend( + $name $(::< $( $generic_default ),* >)? (templates, now, rng)? + .into_iter() + .map(|(sample_identifier, rendered)| (($template, sample_identifier), rendered)) + ); + )* + + Ok(out) + } + $( #[doc = concat!("Render the `", $template, "` template with sample contexts")] /// + /// Returns the sample renders. + /// /// # Errors /// /// Returns an error if the template fails to render with any of the sample. pub(crate) fn $name $(< $( $lt $( : $clt $(+ $dlt )* + TemplateContext )? ),+ >)? (templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) - -> anyhow::Result<()> { + -> anyhow::Result> { let locales = templates.translator().available_locales(); - let samples: Vec< $param > = TemplateContext::sample(now, rng, &locales); + let samples: BTreeMap = TemplateContext::sample(now, rng, &locales); let name = $template; - for sample in samples { + let mut out = BTreeMap::new(); + for (sample_identifier, sample) in samples { let context = serde_json::to_value(&sample)?; ::tracing::info!(name, %context, "Rendering template"); - templates. $name (&sample) - .with_context(|| format!("Failed to render template {:?} with context {}", name, context))?; + let rendered = templates. $name (&sample) + .with_context(|| format!("Failed to render sample template {name:?}-{sample_identifier:?} with context {context}"))?; + out.insert(sample_identifier, rendered); } - Ok(()) + Ok(out) } )* } diff --git a/crates/tower/Cargo.toml b/crates/tower/Cargo.toml index 38067f120..44a17c670 100644 --- a/crates/tower/Cargo.toml +++ b/crates/tower/Cargo.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [package] name = "mas-tower" description = "Tower layers used by the Matrix Authentication Service" diff --git a/crates/tower/src/lib.rs b/crates/tower/src/lib.rs index 7c3bd077b..72fd97c9e 100644 --- a/crates/tower/src/lib.rs +++ b/crates/tower/src/lib.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. #![allow(clippy::module_name_repetitions)] diff --git a/crates/tower/src/metrics/duration.rs b/crates/tower/src/metrics/duration.rs index e0664fb31..d8f3cc52f 100644 --- a/crates/tower/src/metrics/duration.rs +++ b/crates/tower/src/metrics/duration.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::time::Instant; diff --git a/crates/tower/src/metrics/in_flight.rs b/crates/tower/src/metrics/in_flight.rs index 8f362c6cb..02a21540f 100644 --- a/crates/tower/src/metrics/in_flight.rs +++ b/crates/tower/src/metrics/in_flight.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use opentelemetry::{KeyValue, metrics::UpDownCounter}; use pin_project_lite::pin_project; diff --git a/crates/tower/src/metrics/make_attributes.rs b/crates/tower/src/metrics/make_attributes.rs index a6e38378d..e4bc3ca68 100644 --- a/crates/tower/src/metrics/make_attributes.rs +++ b/crates/tower/src/metrics/make_attributes.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use opentelemetry::{KeyValue, Value}; diff --git a/crates/tower/src/metrics/mod.rs b/crates/tower/src/metrics/mod.rs index da919af01..9b9094c77 100644 --- a/crates/tower/src/metrics/mod.rs +++ b/crates/tower/src/metrics/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod duration; mod in_flight; diff --git a/crates/tower/src/trace_context.rs b/crates/tower/src/trace_context.rs index 3621490c5..9180201c6 100644 --- a/crates/tower/src/trace_context.rs +++ b/crates/tower/src/trace_context.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use http::Request; use opentelemetry::propagation::Injector; diff --git a/crates/tower/src/tracing/enrich_span.rs b/crates/tower/src/tracing/enrich_span.rs index 9d2b83c84..1c726ba3b 100644 --- a/crates/tower/src/tracing/enrich_span.rs +++ b/crates/tower/src/tracing/enrich_span.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use tracing::{Span, Value}; diff --git a/crates/tower/src/tracing/future.rs b/crates/tower/src/tracing/future.rs index aeec33f39..bac624281 100644 --- a/crates/tower/src/tracing/future.rs +++ b/crates/tower/src/tracing/future.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use std::task::ready; diff --git a/crates/tower/src/tracing/layer.rs b/crates/tower/src/tracing/layer.rs index 70ad27954..1da80e90d 100644 --- a/crates/tower/src/tracing/layer.rs +++ b/crates/tower/src/tracing/layer.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use tower::Layer; use tracing::Span; diff --git a/crates/tower/src/tracing/make_span.rs b/crates/tower/src/tracing/make_span.rs index f84cab73e..ba03f47e6 100644 --- a/crates/tower/src/tracing/make_span.rs +++ b/crates/tower/src/tracing/make_span.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use tracing::Span; diff --git a/crates/tower/src/tracing/mod.rs b/crates/tower/src/tracing/mod.rs index bcd34fad8..0c355e654 100644 --- a/crates/tower/src/tracing/mod.rs +++ b/crates/tower/src/tracing/mod.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. mod enrich_span; mod future; diff --git a/crates/tower/src/tracing/service.rs b/crates/tower/src/tracing/service.rs index 8a292ad83..9c3484519 100644 --- a/crates/tower/src/tracing/service.rs +++ b/crates/tower/src/tracing/service.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use tower::Service; diff --git a/crates/tower/src/utils.rs b/crates/tower/src/utils.rs index b0c7a5b18..c9a6e9e4f 100644 --- a/crates/tower/src/utils.rs +++ b/crates/tower/src/utils.rs @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. use opentelemetry::{KeyValue, Value}; use tower::{Layer, Service}; diff --git a/deny.toml b/deny.toml index 6d69a4b38..1671119ca 100644 --- a/deny.toml +++ b/deny.toml @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + [graph] targets = [ { triple = "x86_64-unknown-linux-gnu" }, @@ -14,17 +19,13 @@ ignore = [ # RSA key extraction "Marvin Attack". This is only relevant when using # PKCS#1 v1.5 encryption, which we don't "RUSTSEC-2023-0071", - - # `paste`, as used by `aws-lc-rs` is unmaintained, but we're not concerned - # about it having a security vulnerability - "RUSTSEC-2024-0436", ] [licenses] version = 2 allow = [ + "LicenseRef-Element-Commercial", "0BSD", - "AGPL-3.0", "Apache-2.0 WITH LLVM-exception", "Apache-2.0", "BSD-2-Clause", @@ -35,6 +36,7 @@ allow = [ "OpenSSL", "Unicode-3.0", "Zlib", + "CDLA-Permissive-2.0", ] # Ring's license is a bit complicated, so we need to specify it manually @@ -57,8 +59,6 @@ deny = [ multiple-versions = "deny" skip = [ - { name = "regex-syntax", version = "0.6.29" }, # tracing-subscriber[env-filter] -> matchers depends on the old version - { name = "regex-automata", version = "0.1.10" }, # ^ { name = "itertools", version = "0.13.0" }, # zxcvbn depends on this old version { name = "hashbrown", version = "0.14.5" }, # a few crates depend on this old version # a few dependencies depend on the 1.x version of thiserror @@ -66,11 +66,11 @@ skip = [ { name = "thiserror-impl", version = "1.0.69" }, # axum-macros, sqlx-macros and sea-query-attr use an old version { name = "heck", version = "0.4.1" }, - # wasmtime -> cranelift is depending on this old version - { name = "itertools", version = "0.12.1" }, # pad depends on an old version { name = "unicode-width", version = "0.1.14" }, - { name = "zerocopy", version = "0.7.35" }, # hashbrown 0.14.5 depends on this old version + # cron depends on this old version + # https://github.com/zslayton/cron/pull/137 + { name = "winnow", version = "0.6.20" }, # We are still mainly using rand 0.8 { name = "rand", version = "0.8.5" }, diff --git a/docker-bake.hcl b/docker-bake.hcl index cfa6b8353..3c3cac3af 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. +# // This is used to set the version reported by the binary through an environment // variable. This is mainly useful when building out of a git context, like in // CI, where we don't have the full commit history available diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 1857fa42b..211047872 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -21,6 +21,7 @@ - [Policy engine](./topics/policy.md) - [Authorization and sessions](./topics/authorization.md) - [Use the Admin API](./topics/admin-api.md) +- [Get an access token](./topics/access-token.md) # Reference diff --git a/docs/api/spec.json b/docs/api/spec.json index c5d6c6fbd..0c0975c41 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -16,6 +16,65 @@ } ], "paths": { + "/api/admin/v1/site-config": { + "get": { + "tags": [ + "server" + ], + "summary": "Get informations about the configuration of this MAS instance", + "operationId": "siteConfig", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteConfig" + }, + "example": { + "server_name": "example.com", + "password_login_enabled": true, + "password_registration_enabled": true, + "password_registration_email_required": true, + "registration_token_required": true, + "email_change_allowed": true, + "displayname_change_allowed": true, + "password_change_allowed": true, + "account_recovery_allowed": true, + "account_deactivation_allowed": true, + "captcha_enabled": true, + "minimum_password_complexity": 3 + } + } + } + } + } + } + }, + "/api/admin/v1/version": { + "get": { + "tags": [ + "server" + ], + "summary": "Get the version currently running", + "operationId": "version", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + }, + "example": { + "version": "v1.0.0" + } + } + } + } + } + } + }, "/api/admin/v1/compat-sessions": { "get": { "tags": [ @@ -89,6 +148,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -171,6 +247,11 @@ }, "links": { "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -190,6 +271,11 @@ }, "links": { "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -209,6 +295,11 @@ }, "links": { "self": "/api/admin/v1/compat-sessions/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -316,6 +407,98 @@ } } }, + "/api/admin/v1/compat-sessions/{id}/finish": { + "post": { + "tags": [ + "compat-session" + ], + "summary": "Finish a compatibility session", + "description": "Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.", + "operationId": "finishCompatSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Compatibility session was finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_CompatSession" + }, + "example": { + "data": { + "type": "compat-session", + "id": "02081040G2081040G2081040G2", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": "FFGGHHIIJJ", + "user_session_id": "0J289144GJ289144GJ289144GJ", + "redirect_uri": null, + "created_at": "1970-01-01T00:00:00Z", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "1.2.3.4", + "finished_at": "1970-01-01T00:00:00Z", + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" + } + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2/finish" + } + } + } + } + }, + "400": { + "description": "Session is already finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session with ID 00000000000000000000000000 is already finished" + } + ] + } + } + } + }, + "404": { + "description": "Compatibility session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/oauth2-sessions": { "get": { "tags": [ @@ -389,6 +572,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -519,6 +719,11 @@ }, "links": { "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -538,6 +743,11 @@ }, "links": { "self": "/api/admin/v1/oauth2-sessions/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -549,7 +759,7 @@ "user_id": "040G2081040G2081040G208104", "user_session_id": "050M2GA1850M2GA1850M2GA185", "client_id": "060R30C1G60R30C1G60R30C1G6", - "scope": "urn:matrix:org.matrix.msc2967.client:api:*", + "scope": "urn:matrix:client:api:*", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", "last_active_ip": "127.0.0.1", @@ -557,6 +767,11 @@ }, "links": { "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -681,6 +896,716 @@ } } }, + "/api/admin/v1/oauth2-sessions/{id}/finish": { + "post": { + "tags": [ + "oauth2-session" + ], + "summary": "Finish an OAuth 2.0 session", + "description": "Calling this endpoint will finish the OAuth 2.0 session, preventing any further use. If the session has a user associated with it, a job will be scheduled to sync the user's devices with the homeserver.", + "operationId": "finishOAuth2Session", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "OAuth 2.0 session was finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_OAuth2Session" + }, + "example": { + "data": { + "type": "oauth2-session", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "user_id": "040G2081040G2081040G208104", + "user_session_id": "050M2GA1850M2GA1850M2GA185", + "client_id": "060R30C1G60R30C1G60R30C1G6", + "scope": "urn:matrix:client:api:*", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "127.0.0.1", + "human_name": null + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" + } + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3/finish" + } + } + } + } + }, + "400": { + "description": "Session is already finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "OAuth 2.0 session with ID 00000000000000000000000000 is already finished" + } + ] + } + } + } + }, + "404": { + "description": "OAuth 2.0 session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "OAuth 2.0 session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/personal-sessions": { + "get": { + "tags": [ + "personal-session" + ], + "summary": "List personal sessions", + "description": "Retrieve a list of personal sessions.\nNote that by default, all sessions, including revoked ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.", + "operationId": "listPersonalSessions", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[owner_user]", + "description": "Filter by owner user ID", + "schema": { + "description": "Filter by owner user ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[owner_client]", + "description": "Filter by owner `OAuth2` client ID", + "schema": { + "description": "Filter by owner `OAuth2` client ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[actor_user]", + "description": "Filter by actor user ID", + "schema": { + "description": "Filter by actor user ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[scope]", + "description": "Retrieve the items with the given scope", + "schema": { + "description": "Retrieve the items with the given scope", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[status]", + "description": "Filter by session status", + "schema": { + "description": "Filter by session status", + "anyOf": [ + { + "$ref": "#/components/schemas/PersonalSessionStatus" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expires_before]", + "description": "Filter by access token expiry date", + "schema": { + "description": "Filter by access token expiry date", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expires_after]", + "description": "Filter by access token expiry date", + "schema": { + "description": "Filter by access token expiry date", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expires]", + "description": "Filter by whether the access token has an expiry time", + "schema": { + "description": "Filter by whether the access token has an expiry time", + "type": [ + "boolean", + "null" + ] + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of personal sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_PersonalSession" + }, + "example": { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100", + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + }, + { + "type": "personal-session", + "id": "01FSHN9AG0BJ6AC5HQ9X6H4RP5", + "attributes": { + "created_at": "2022-01-16T13:01:00Z", + "revoked_at": "2022-01-16T16:20:00Z", + "owner_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F", + "human_name": "Bob's Mobile App", + "scope": "openid", + "last_active_at": "2022-01-16T16:03:20Z", + "last_active_ip": "10.0.0.50", + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0BJ6AC5HQ9X6H4RP5" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0BJ6AC5HQ9X6H4RP5" + } + } + }, + { + "type": "personal-session", + "id": "01FSHN9AG0CJ6AC5HQ9X6H4RP6", + "attributes": { + "created_at": "2022-01-16T13:02:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0DJ6AC5HQ9X6H4RP7", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "CI/CD Pipeline Token", + "scope": "openid urn:mas:admin", + "last_active_at": "2022-01-16T15:46:40Z", + "last_active_ip": "203.0.113.10", + "expires_at": "2022-01-24T04:36:40Z" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0CJ6AC5HQ9X6H4RP6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0CJ6AC5HQ9X6H4RP6" + } + } + } + ], + "links": { + "self": "/api/admin/v1/personal-sessions?page[first]=3", + "first": "/api/admin/v1/personal-sessions?page[first]=3", + "last": "/api/admin/v1/personal-sessions?page[last]=3", + "next": "/api/admin/v1/personal-sessions?page[after]=01FSHN9AG0CJ6AC5HQ9X6H4RP6&page[first]=3" + } + } + } + } + }, + "404": { + "description": "Client was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Client ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + }, + "post": { + "tags": [ + "personal-session" + ], + "summary": "Create a new personal session with personal access token", + "operationId": "createPersonalSession", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonalSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Personal session and personal access token were created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" + } + } + } + }, + "400": { + "description": "Invalid scope provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Invalid scope" + } + ] + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/personal-sessions/{id}": { + "get": { + "tags": [ + "personal-session" + ], + "summary": "Get a personal session", + "operationId": "getPersonalSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Personal session details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" + }, + "example": { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100", + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + } + } + }, + "404": { + "description": "Personal session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Personal session not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/personal-sessions/{id}/revoke": { + "post": { + "tags": [ + "personal-session" + ], + "summary": "Revoke a personal session", + "operationId": "revokePersonalSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Personal session was revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" + }, + "example": { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100", + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + } + } + }, + "404": { + "description": "Personal session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Personal session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + }, + "409": { + "description": "Personal session already revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Personal session with ID 00000000000000000000000000 is already revoked" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/personal-sessions/{id}/regenerate": { + "post": { + "tags": [ + "personal-session" + ], + "summary": "Regenerate a personal session by replacing its personal access token", + "operationId": "regeneratePersonalSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegeneratePersonalSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Personal session was regenerated and a personal access token was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/policy-data": { "post": { "tags": [ @@ -955,6 +1880,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[admin]", @@ -968,6 +1910,32 @@ }, "style": "form" }, + { + "in": "query", + "name": "filter[legacy-guest]", + "description": "Retrieve users with (or without) the `legacy_guest` flag set", + "schema": { + "description": "Retrieve users with (or without) the `legacy_guest` flag set", + "type": [ + "boolean", + "null" + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[search]", + "description": "Retrieve users where the username matches contains the given string\n\n Note that this doesn't change the ordering of the result, which are\n still ordered by ID.", + "schema": { + "description": "Retrieve users where the username matches contains the given string\n\n Note that this doesn't change the ordering of the result, which are\n still ordered by ID.", + "type": [ + "string", + "null" + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[status]", @@ -1007,10 +1975,16 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -1021,10 +1995,16 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": true + "admin": true, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -1035,10 +2015,16 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": "1970-01-01T00:00:00Z", "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": true }, "links": { "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -1087,7 +2073,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -1173,7 +2160,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -1330,7 +2318,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -1410,7 +2399,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": true + "admin": true, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/02081040G2081040G2081040G2" @@ -1449,7 +2439,7 @@ "user" ], "summary": "Deactivate a user", - "description": "Calling this endpoint will lock and deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", + "description": "Calling this endpoint will deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", "operationId": "deactivateUser", "parameters": [ { @@ -1463,6 +2453,15 @@ "style": "simple" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeactivateUserRequest" + } + } + } + }, "responses": { "200": { "description": "User was deactivated", @@ -1480,7 +2479,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": "1970-01-01T00:00:00Z", "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": true }, "links": { "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" @@ -1513,6 +2513,77 @@ } } }, + "/api/admin/v1/users/{id}/reactivate": { + "post": { + "tags": [ + "user" + ], + "summary": "Reactivate a user", + "description": "Calling this endpoint will reactivate a deactivated user.\nThis DOES NOT unlock a locked user, which is still prevented from doing any action until it is explicitly unlocked.", + "operationId": "reactivateUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was reactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081/reactivate" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/users/{id}/lock": { "post": { "tags": [ @@ -1550,7 +2621,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": "1970-01-01T00:00:00Z", "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": true }, "links": { "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" @@ -1589,6 +2661,7 @@ "user" ], "summary": "Unlock a user", + "description": "Calling this endpoint will lift restrictions on user actions that had imposed by locking.\nThis DOES NOT reactivate a deactivated user, which will remain unavailable until it is explicitly reactivated.", "operationId": "unlockUser", "parameters": [ { @@ -1619,7 +2692,8 @@ "created_at": "1970-01-01T00:00:00Z", "locked_at": null, "deactivated_at": null, - "admin": false + "admin": false, + "legacy_guest": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -1725,6 +2799,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -1779,6 +2870,11 @@ }, "links": { "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } } ], @@ -2094,6 +3190,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -2155,6 +3268,11 @@ }, "links": { "self": "/api/admin/v1/user-sessions/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -2170,6 +3288,11 @@ }, "links": { "self": "/api/admin/v1/user-sessions/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -2185,6 +3308,11 @@ }, "links": { "self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -2288,6 +3416,94 @@ } } }, + "/api/admin/v1/user-sessions/{id}/finish": { + "post": { + "tags": [ + "user-session" + ], + "summary": "Finish a user session", + "description": "Calling this endpoint will finish the user session, preventing any further use.", + "operationId": "finishUserSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User session was finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserSession" + }, + "example": { + "data": { + "type": "user-session", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "user_id": "040G2081040G2081040G208104", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "127.0.0.1" + }, + "links": { + "self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3" + } + }, + "links": { + "self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3/finish" + } + } + } + } + }, + "400": { + "description": "Session is already finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User session with ID 00000000000000000000000000 is already finished" + } + ] + } + } + } + }, + "404": { + "description": "User session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/user-registration-tokens": { "get": { "tags": [ @@ -2360,6 +3576,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[used]", @@ -2441,6 +3674,11 @@ }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -2458,6 +3696,11 @@ }, "links": { "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } } ], @@ -2931,6 +4174,23 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -3004,6 +4264,11 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -3018,6 +4283,11 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-links/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -3032,6 +4302,11 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-links/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -3285,6 +4560,255 @@ } } } + }, + "/api/admin/v1/upstream-oauth-providers": { + "get": { + "tags": [ + "upstream-oauth-provider" + ], + "summary": "List upstream OAuth 2.0 providers", + "operationId": "listUpstreamOAuthProviders", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "type": "null" + } + ] + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[enabled]", + "description": "Retrieve providers that are (or are not) enabled", + "schema": { + "description": "Retrieve providers that are (or are not) enabled", + "type": [ + "boolean", + "null" + ] + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of upstream OAuth 2.0 providers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_UpstreamOAuthProvider" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01040G2081040G2081040G2081", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "02081040G2081040G2081040G2", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": "1970-01-01T00:00:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "issuer": null, + "human_name": "Custom OAuth Provider", + "brand_name": null, + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?page[first]=3", + "first": "/api/admin/v1/upstream-oauth-providers?page[first]=3", + "last": "/api/admin/v1/upstream-oauth-providers?page[last]=3", + "next": "/api/admin/v1/upstream-oauth-providers?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + } + } + } + } + } + } + } + }, + "/api/admin/v1/upstream-oauth-providers/{id}": { + "get": { + "tags": [ + "upstream-oauth-provider" + ], + "summary": "Get upstream OAuth provider", + "operationId": "getUpstreamOAuthProvider", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "The upstream OAuth provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UpstreamOAuthProvider" + }, + "example": { + "data": { + "type": "upstream-oauth-provider", + "id": "01040G2081040G2081040G2081", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Provider not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -3316,6 +4840,88 @@ } }, "schemas": { + "SiteConfig": { + "type": "object", + "properties": { + "server_name": { + "description": "The Matrix server name for which this instance is configured", + "type": "string" + }, + "password_login_enabled": { + "description": "Whether password login is enabled.", + "type": "boolean" + }, + "password_registration_enabled": { + "description": "Whether password registration is enabled.", + "type": "boolean" + }, + "password_registration_email_required": { + "description": "Whether a valid email address is required for password registrations.", + "type": "boolean" + }, + "registration_token_required": { + "description": "Whether registration tokens are required for password registrations.", + "type": "boolean" + }, + "email_change_allowed": { + "description": "Whether users can change their email.", + "type": "boolean" + }, + "displayname_change_allowed": { + "description": "Whether users can change their display name.", + "type": "boolean" + }, + "password_change_allowed": { + "description": "Whether users can change their password.", + "type": "boolean" + }, + "account_recovery_allowed": { + "description": "Whether users can recover their account via email.", + "type": "boolean" + }, + "account_deactivation_allowed": { + "description": "Whether users can delete their own account.", + "type": "boolean" + }, + "captcha_enabled": { + "description": "Whether CAPTCHA during registration is enabled.", + "type": "boolean" + }, + "minimum_password_complexity": { + "description": "Minimum password complexity, between 0 and 4.\n This is a score from zxcvbn.", + "type": "integer", + "format": "uint8", + "minimum": 0, + "maximum": 4 + } + }, + "required": [ + "server_name", + "password_login_enabled", + "password_registration_enabled", + "password_registration_email_required", + "registration_token_required", + "email_change_allowed", + "displayname_change_allowed", + "password_change_allowed", + "account_recovery_allowed", + "account_deactivation_allowed", + "captcha_enabled", + "minimum_password_complexity" + ] + }, + "Version": { + "type": "object", + "properties": { + "version": { + "description": "The semver version of the app", + "type": "string" + } + }, + "required": [ + "version" + ] + }, "PaginationParams": { "type": "object", "properties": { @@ -3360,6 +4966,20 @@ "format": "uint", "minimum": 1, "nullable": true + }, + "count": { + "description": "Include the total number of items. Defaults to `true`.", + "anyOf": [ + { + "$ref": "#/components/schemas/IncludeCount" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } } }, @@ -3370,6 +4990,31 @@ "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$", "example": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, + "IncludeCount": { + "oneOf": [ + { + "description": "Include the total number of items (default)", + "type": "string", + "enum": [ + "true" + ] + }, + { + "description": "Do not include the total number of items", + "type": "string", + "enum": [ + "false" + ] + }, + { + "description": "Only include the total number of items, skip the items themselves", + "type": "string", + "enum": [ + "only" + ] + } + ] + }, "CompatSessionFilter": { "type": "object", "properties": { @@ -3430,9 +5075,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -3441,7 +5092,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_CompatSession" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -3453,8 +5105,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -3465,12 +5115,10 @@ "description": "The total number of results", "type": "integer", "format": "uint", - "minimum": 0 + "minimum": 0, + "nullable": true } - }, - "required": [ - "count" - ] + } }, "SingleResource_for_CompatSession": { "description": "A single resource, with its type, ID, attributes and related links", @@ -3503,6 +5151,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -3607,6 +5269,39 @@ "self" ] }, + "SingleResourceMeta": { + "description": "Metadata associated with a resource", + "type": "object", + "properties": { + "page": { + "description": "Information about the pagination of the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMetaPage" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + } + } + }, + "SingleResourceMetaPage": { + "description": "Pagination metadata for a resource", + "type": "object", + "properties": { + "cursor": { + "description": "The cursor of this resource in the paginated result", + "type": "string" + } + }, + "required": [ + "cursor" + ] + }, "PaginationLinks": { "description": "Related links", "type": "object", @@ -3617,11 +5312,13 @@ }, "first": { "description": "The link to the first page of results", - "type": "string" + "type": "string", + "nullable": true }, "last": { "description": "The link to the last page of results", - "type": "string" + "type": "string", + "nullable": true }, "next": { "description": "The link to the next page of results\n\n Only present if there is a next page", @@ -3635,9 +5332,7 @@ } }, "required": [ - "self", - "first", - "last" + "self" ] }, "ErrorResponse": { @@ -3804,9 +5499,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -3815,7 +5516,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -3827,8 +5529,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -3863,6 +5563,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -3972,6 +5686,345 @@ "links" ] }, + "PersonalSessionFilter": { + "type": "object", + "properties": { + "filter[owner_user]": { + "description": "Filter by owner user ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "filter[owner_client]": { + "description": "Filter by owner `OAuth2` client ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "filter[actor_user]": { + "description": "Filter by actor user ID", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "filter[scope]": { + "description": "Retrieve the items with the given scope", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "filter[status]": { + "description": "Filter by session status", + "anyOf": [ + { + "$ref": "#/components/schemas/PersonalSessionStatus" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "filter[expires_before]": { + "description": "Filter by access token expiry date", + "type": "string", + "format": "date-time", + "nullable": true + }, + "filter[expires_after]": { + "description": "Filter by access token expiry date", + "type": "string", + "format": "date-time", + "nullable": true + }, + "filter[expires]": { + "description": "Filter by whether the access token has an expiry time", + "type": "boolean", + "nullable": true + } + } + }, + "PersonalSessionStatus": { + "type": "string", + "enum": [ + "active", + "revoked" + ] + }, + "PaginatedResponse_for_PersonalSession": { + "description": "A top-level response with a page of resources", + "type": "object", + "properties": { + "meta": { + "description": "Response metadata", + "anyOf": [ + { + "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_PersonalSession" + }, + "nullable": true + }, + "links": { + "description": "Related links", + "allOf": [ + { + "$ref": "#/components/schemas/PaginationLinks" + } + ] + } + }, + "required": [ + "links" + ] + }, + "SingleResource_for_PersonalSession": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "allOf": [ + { + "$ref": "#/components/schemas/ULID" + } + ] + }, + "attributes": { + "description": "The attributes of the resource", + "allOf": [ + { + "$ref": "#/components/schemas/PersonalSession" + } + ] + }, + "links": { + "description": "Related links", + "allOf": [ + { + "$ref": "#/components/schemas/SelfLinks" + } + ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + } + }, + "required": [ + "type", + "id", + "attributes", + "links" + ] + }, + "PersonalSession": { + "description": "A personal session (session using personal access tokens)", + "type": "object", + "properties": { + "created_at": { + "description": "When the session was created", + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "description": "When the session was revoked, if applicable", + "type": "string", + "format": "date-time", + "nullable": true + }, + "owner_user_id": { + "description": "The ID of the user who owns this session (if user-owned)", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "owner_client_id": { + "description": "The ID of the `OAuth2` client that owns this session (if client-owned)", + "anyOf": [ + { + "$ref": "#/components/schemas/ULID" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "actor_user_id": { + "description": "The ID of the user that the session acts on behalf of", + "allOf": [ + { + "$ref": "#/components/schemas/ULID" + } + ] + }, + "human_name": { + "description": "Human-readable name for the session", + "type": "string" + }, + "scope": { + "description": "`OAuth2` scopes for this session", + "type": "string" + }, + "last_active_at": { + "description": "When the session was last active", + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_active_ip": { + "description": "IP address of last activity", + "type": "string", + "format": "ip", + "nullable": true + }, + "expires_at": { + "description": "When the current token for this session expires.\n The session will need to be regenerated, producing a new access token,\n after this time.\n None if the current token won't expire or if the session is revoked.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "access_token": { + "description": "The actual access token (only returned on creation)", + "type": "string", + "nullable": true + } + }, + "required": [ + "created_at", + "actor_user_id", + "human_name", + "scope" + ] + }, + "CreatePersonalSessionRequest": { + "title": "JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint", + "type": "object", + "properties": { + "actor_user_id": { + "description": "The user this session will act on behalf of", + "allOf": [ + { + "$ref": "#/components/schemas/ULID" + } + ] + }, + "human_name": { + "description": "Human-readable name for the session", + "type": "string" + }, + "scope": { + "description": "`OAuth2` scopes for this session", + "type": "string" + }, + "expires_in": { + "description": "Token expiry time in seconds.\n If not set, the token won't expire.", + "type": "integer", + "format": "uint32", + "minimum": 0, + "nullable": true + } + }, + "required": [ + "actor_user_id", + "human_name", + "scope" + ] + }, + "SingleResponse_for_PersonalSession": { + "description": "A top-level response with a single resource", + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_PersonalSession" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + }, + "required": [ + "data", + "links" + ] + }, + "RegeneratePersonalSessionRequest": { + "title": "JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint", + "type": "object", + "properties": { + "expires_in": { + "description": "Token expiry time in seconds.\n If not set, the token won't expire.", + "type": "integer", + "format": "uint32", + "minimum": 0, + "nullable": true + } + } + }, "SetPolicyDataRequest": { "title": "JSON payload for the `POST /api/admin/v1/policy-data`", "type": "object", @@ -4035,6 +6088,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -4070,6 +6137,16 @@ "type": "boolean", "nullable": true }, + "filter[legacy-guest]": { + "description": "Retrieve users with (or without) the `legacy_guest` flag set", + "type": "boolean", + "nullable": true + }, + "filter[search]": { + "description": "Retrieve users where the username matches contains the given string\n\n Note that this doesn't change the ordering of the result, which are\n still ordered by ID.", + "type": "string", + "nullable": true + }, "filter[status]": { "description": "Retrieve the items with the given status\n\n Defaults to retrieve all users, including locked ones.\n\n * `active`: Only retrieve active users\n\n * `locked`: Only retrieve locked users (includes deactivated users)\n\n * `deactivated`: Only retrieve deactivated users", "anyOf": [ @@ -4100,9 +6177,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -4111,7 +6194,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_User" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4123,8 +6207,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -4159,6 +6241,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -4196,12 +6292,17 @@ "admin": { "description": "Whether the user can request admin privileges.", "type": "boolean" + }, + "legacy_guest": { + "description": "Whether the user was a guest before migrating to MAS,", + "type": "boolean" } }, "required": [ "username", "created_at", - "admin" + "admin", + "legacy_guest" ] }, "AddUserRequest": { @@ -4282,6 +6383,17 @@ "admin" ] }, + "DeactivateUserRequest": { + "title": "JSON payload for the `POST /api/admin/v1/users/:id/deactivate` endpoint", + "type": "object", + "properties": { + "skip_erase": { + "description": "Whether to skip requesting the homeserver to GDPR-erase the user upon\n deactivation.", + "type": "boolean", + "default": false + } + } + }, "UserEmailFilter": { "type": "object", "properties": { @@ -4312,9 +6424,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -4323,7 +6441,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UserEmail" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4335,8 +6454,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -4371,6 +6488,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -4493,9 +6624,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -4504,7 +6641,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UserSession" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4516,8 +6654,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -4552,6 +6688,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -4654,9 +6804,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -4665,7 +6821,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UserRegistrationToken" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4677,8 +6834,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -4713,6 +6868,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -4881,9 +7050,15 @@ "properties": { "meta": { "description": "Response metadata", - "allOf": [ + "anyOf": [ { "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] } ] }, @@ -4892,7 +7067,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthLink" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4904,8 +7080,6 @@ } }, "required": [ - "meta", - "data", "links" ] }, @@ -4940,6 +7114,20 @@ "$ref": "#/components/schemas/SelfLinks" } ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] } }, "required": [ @@ -5047,6 +7235,160 @@ "data", "links" ] + }, + "UpstreamOAuthProviderFilter": { + "type": "object", + "properties": { + "filter[enabled]": { + "description": "Retrieve providers that are (or are not) enabled", + "type": "boolean", + "nullable": true + } + } + }, + "PaginatedResponse_for_UpstreamOAuthProvider": { + "description": "A top-level response with a page of resources", + "type": "object", + "properties": { + "meta": { + "description": "Response metadata", + "anyOf": [ + { + "$ref": "#/components/schemas/PaginationMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthProvider" + }, + "nullable": true + }, + "links": { + "description": "Related links", + "allOf": [ + { + "$ref": "#/components/schemas/PaginationLinks" + } + ] + } + }, + "required": [ + "links" + ] + }, + "SingleResource_for_UpstreamOAuthProvider": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "allOf": [ + { + "$ref": "#/components/schemas/ULID" + } + ] + }, + "attributes": { + "description": "The attributes of the resource", + "allOf": [ + { + "$ref": "#/components/schemas/UpstreamOAuthProvider" + } + ] + }, + "links": { + "description": "Related links", + "allOf": [ + { + "$ref": "#/components/schemas/SelfLinks" + } + ] + }, + "meta": { + "description": "Metadata about the resource", + "anyOf": [ + { + "$ref": "#/components/schemas/SingleResourceMeta" + }, + { + "nullable": true, + "enum": [ + null + ] + } + ] + } + }, + "required": [ + "type", + "id", + "attributes", + "links" + ] + }, + "UpstreamOAuthProvider": { + "description": "An upstream OAuth 2.0 provider", + "type": "object", + "properties": { + "issuer": { + "description": "The OIDC issuer of the provider", + "type": "string", + "nullable": true + }, + "human_name": { + "description": "A human-readable name for the provider", + "type": "string", + "nullable": true + }, + "brand_name": { + "description": "A brand identifier, e.g. \"apple\" or \"google\"", + "type": "string", + "nullable": true + }, + "created_at": { + "description": "When the provider was created", + "type": "string", + "format": "date-time" + }, + "disabled_at": { + "description": "When the provider was disabled. If null, the provider is enabled.", + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "created_at" + ] + }, + "SingleResponse_for_UpstreamOAuthProvider": { + "description": "A top-level response with a single resource", + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthProvider" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + }, + "required": [ + "data", + "links" + ] } } }, @@ -5063,6 +7405,10 @@ } ], "tags": [ + { + "name": "server", + "description": "Information about the server" + }, { "name": "compat-session", "description": "Manage compatibility sessions from legacy clients" @@ -5094,6 +7440,10 @@ { "name": "upstream-oauth-link", "description": "Manage links between local users and identities from upstream OAuth 2.0 providers" + }, + { + "name": "upstream-oauth-provider", + "description": "Manage upstream OAuth 2.0 providers" } ] } diff --git a/docs/config.schema.json b/docs/config.schema.json index d69168bc8..7412c2478 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -240,8 +240,13 @@ "type": "string", "nullable": true }, + "client_secret_file": { + "description": "Path to the file containing the client secret. The client secret is used\n by the `client_secret_basic`, `client_secret_post` and\n `client_secret_jwt` authentication methods.", + "type": "string", + "nullable": true + }, "client_secret": { - "description": "The client secret, used by the `client_secret_basic`,\n `client_secret_post` and `client_secret_jwt` authentication methods", + "description": "Alternative to `client_secret_file`: Reads the client secret directly\n from the config.", "type": "string", "nullable": true }, @@ -1561,7 +1566,9 @@ "type": "object", "properties": { "kid": { - "type": "string" + "description": "The key ID `kid` of the key as used by JWKs.\n\n If not given, `kid` will be the key’s RFC 7638 JWK Thumbprint.", + "type": "string", + "nullable": true }, "password_file": { "type": "string", @@ -1579,10 +1586,7 @@ "type": "string", "nullable": true } - }, - "required": [ - "kid" - ] + } }, "PasswordsConfig": { "description": "User password hashing config", @@ -1701,9 +1705,13 @@ "type": "string", "default": "localhost:8008" }, + "secret_file": { + "type": "string", + "nullable": true + }, "secret": { - "description": "Shared secret to use for calls to the admin API", - "type": "string" + "type": "string", + "nullable": true }, "endpoint": { "description": "The base URL of the homeserver's client API", @@ -1711,23 +1719,30 @@ "format": "uri", "default": "http://localhost:8008/" } - }, - "required": [ - "secret" - ] + } }, "HomeserverKind": { "description": "The kind of homeserver it is.", "oneOf": [ { - "description": "Homeserver is Synapse", + "description": "Homeserver is Synapse, version 1.135.0 or newer", "type": "string", "const": "synapse" }, { - "description": "Homeserver is Synapse, in read-only mode\n\n This is meant for testing rolling out Matrix Authentication Service with\n no risk of writing data to the homeserver.", + "description": "Homeserver is Synapse, version 1.135.0 or newer, in read-only mode\n\n This is meant for testing rolling out Matrix Authentication Service with\n no risk of writing data to the homeserver.", "type": "string", "const": "synapse_read_only" + }, + { + "description": "Homeserver is Synapse, using the legacy API", + "type": "string", + "const": "synapse_legacy" + }, + { + "description": "Homeserver is Synapse, with the modern API available (>= 1.135.0)", + "type": "string", + "const": "synapse_modern" } ] }, @@ -2165,6 +2180,14 @@ "description": "Whether the `login_hint` should be forwarded to the provider in the\n authorization request.\n\n Defaults to `false`.", "type": "boolean", "default": false + }, + "on_backchannel_logout": { + "description": "What to do when receiving an OIDC Backchannel logout request.\n\n Defaults to `do_nothing`.", + "allOf": [ + { + "$ref": "#/definitions/OnBackchannelLogout" + } + ] } }, "required": [ @@ -2363,6 +2386,14 @@ "description": "The Jinja2 template to use for the localpart attribute\n\n If not provided, the default template is `{{ user.preferred_username }}`", "type": "string", "nullable": true + }, + "on_conflict": { + "description": "How to handle conflicts on the claim, default value is `Fail`", + "allOf": [ + { + "$ref": "#/definitions/OnConflict" + } + ] } } }, @@ -2391,6 +2422,21 @@ } ] }, + "OnConflict": { + "description": "How to handle an existing localpart claim", + "oneOf": [ + { + "description": "Fails the sso login on conflict", + "type": "string", + "const": "fail" + }, + { + "description": "Adds the oauth identity link, regardless of whether there is an existing\n link or not", + "type": "string", + "const": "add" + } + ] + }, "DisplaynameImportPreference": { "description": "What should be done for the displayname attribute", "type": "object", @@ -2440,6 +2486,26 @@ } } }, + "OnBackchannelLogout": { + "description": "What to do when receiving an OIDC Backchannel logout request.", + "oneOf": [ + { + "description": "Do nothing", + "type": "string", + "const": "do_nothing" + }, + { + "description": "Only log out the MAS 'browser session' started by this OIDC session", + "type": "string", + "const": "logout_browser_only" + }, + { + "description": "Log out all sessions started by this OIDC session, including MAS\n 'browser sessions' and client sessions", + "type": "string", + "const": "logout_all" + } + ] + }, "BrandingConfig": { "description": "Configuration section for tweaking the branding of the service", "type": "object", @@ -2538,6 +2604,10 @@ "description": "Whether to enable self-service password registration. Defaults to\n `false` if password authentication is enabled.\n\n This has no effect if password login is disabled.", "type": "boolean" }, + "password_registration_email_required": { + "description": "Whether self-service password registrations require a valid email.\n Defaults to `true`.\n\n This has no effect if password registration is disabled.", + "type": "boolean" + }, "password_change_allowed": { "description": "Whether users are allowed to change their passwords. Defaults to `true`.\n\n This has no effect if password login is disabled.", "type": "boolean" diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 83edcfba2..27367b946 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -4,7 +4,7 @@ This document aims to get you started with contributing to the Matrix Authentica ## 1. Who can contribute to MAS? -Everyone is welcome to contribute code to [Synapse](https://github.com/element-hq/matrix-authentication-service), provided that they are willing to license their contributions to Element under a [Contributor License Agreement](https://cla-assistant.io/element-hq/matrix-authentication-service) (CLA). This ensures that their contribution will be made available under an OSI-approved open-source license, currently Affero General Public License v3 (AGPLv3). +Everyone is welcome to contribute code to [Matrix Authentication Service](https://github.com/element-hq/matrix-authentication-service), provided that they are willing to license their contributions to Element under a [Contributor License Agreement](https://cla-assistant.io/element-hq/matrix-authentication-service) (CLA). This ensures that their contribution will be made available under an OSI-approved open-source license, currently Affero General Public License v3 (AGPLv3). Please see the [Element blog post](https://element.io/blog/synapse-now-lives-at-github-com-element-hq-synapse/) for the full rationale. @@ -72,6 +72,7 @@ Make sure your code adheres to our Rust and TypeScript code style by running: - `cargo +nightly fmt` (with the nightly toolchain installed) - `npm run format` in the `frontend` directory + - `make fmt` in the `policies` directory (if changed) When updating SQL queries in the `crates/storage-pg/` crate, you may need to update the `sqlx` introspection data. To do this, make sure to install `cargo-sqlx` (`cargo install sqlx-cli`) and: @@ -86,11 +87,18 @@ While you're developing and before submitting a patch, you'll want to test your - Run `cargo clippy --workspace` to lint the Rust code. - Run `npm run lint` in the `frontend` directory to lint the frontend code. +- Run `make fmt` and `make lint` in the `policies` directory to format and lint the included policy. ### Run the tests -- Run the tests to the backend by running `cargo test --workspace`. This requires a connection to a PostgreSQL database, set via the `DATABASE_URL` environment variable. +If you haven't already, install [Cargo-Nextest](https://nexte.st/docs/installation/pre-built-binaries/). + +- Run the tests to the backend by running `cargo nextest run --workspace`. This requires a connection to a PostgreSQL database, set via the `DATABASE_URL` environment variable. - Run the tests to the frontend by running `npm run test` in the `frontend` directory. +- To run the tests for the included policy, change to the `policies` directory and run one of: + - `make test` (needs Open Policy Agent installed) + - `make PODMAN=1 test` (runs inside a container; needs Podman installed) + - `make DOCKER=1 test` (runs inside a container; needs Docker installed) ## 8. Submit a pull request diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 723815437..faf5b526e 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -97,7 +97,7 @@ At this point, the releaser should check the changelog and ensure the "Set as pr 1. Wait for [CI to churn] and the [draft release to appear]. This takes about 30 minutes. 1. Double-check the changelog on the draft release. 1. Check the "Set as pre-release" checkbox, and publish the release. - 1. Delete the N-2 release branch on [Localazy], meaning that once the 0.16 release cycle begins, the 0.14 release branch will be deleted. + 1. Delete the N-2 release branch on [Localazy](https://localazy.com/console/branching), meaning that once the 0.16 release cycle begins, the 0.14 release branch will be deleted. - Create new release candidates if needed: 1. Run the `translations-download` workflow on the release branch. 1. Wait for the [translation download PR] to be automatically merged. @@ -117,7 +117,7 @@ At this point, the releaser should check the changelog and ensure the "Set as pr [`translations-download` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/translations-download.yaml [`release-branch` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/release-branch.yaml [`release-bump` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/release-bump.yaml -[`build` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/build +[`build` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/build.yaml [translation download PR]: https://github.com/element-hq/matrix-authentication-service/pulls?q=is%3Apr+label%3AA-I18n [CI to churn]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/build.yaml?query=event%3Apush+actor%3Amatrixbot [draft release to appear]: https://github.com/element-hq/matrix-authentication-service/releases diff --git a/docs/reference/cli/manage.md b/docs/reference/cli/manage.md index 0f14f1773..d633c4108 100644 --- a/docs/reference/cli/manage.md +++ b/docs/reference/cli/manage.md @@ -23,6 +23,32 @@ $ mas-cli manage add-email $ mas-cli manage verify-email ``` +## `manage promote-admin` + +Make a user admin. + +``` +$ mas-cli manage promote-admin +``` + +**This doesn't make all the users sessions admin, but rather lets the user request admin access in administration tools.** + +## `manage demote-admin` + +Make a user non-admin. + +``` +$ mas-cli manage demote-admin +``` + +## `manage list-admin-users` + +List all users with admin privileges. + +``` +$ mas-cli manage list-admins +``` + ## `manage set-password` Set a user password. @@ -93,8 +119,11 @@ $ mas-cli manage lock-user --deactivate Unlock a user. +Options: +- `--reactivate`: Whether to reactivate the user. + ``` -$ mas-cli manage unlock-user +$ mas-cli manage unlock-user --reactivate ``` ## `manage register-user` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 3d4f5c4ae..c5b69e38f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -135,7 +135,9 @@ matrix: # Shared secret used to authenticate the service to the homeserver # This must be of high entropy, because leaking this secret would allow anyone to perform admin actions on the homeserver - secret: "SomeRandomSecret" + secret_file: /path/to/secret/file + # Alternatively, the shared secret can be passed inline. + # secret: "SomeRandomSecret" # URL to which the homeserver is accessible from the service endpoint: "http://localhost:8008" @@ -170,7 +172,8 @@ clients: # Confidential client - client_id: 000000000000000000000FIRST client_auth_method: client_secret_post - client_secret: secret + client_secret_file: secret + # OR client_secret: c1!3n753c237 # List of authorized redirect URIs redirect_uris: - http://localhost:1234/callback @@ -194,35 +197,7 @@ secrets: # Signing keys keys: # It needs at least an RSA key to work properly - - kid: "ahM2bien" - key: | - -----BEGIN RSA PRIVATE KEY----- - MIIEowIBAAKCAQEAuf28zPUp574jDRdX6uN0d7niZCIUpACFo+Po/13FuIGsrpze - yMX6CYWVPalgXW9FCrhxL+4toJRy5npjkgsLFsknL5/zXbWKFgt69cMwsWJ9Ra57 - bonSlI7SoCuHhtw7j+sAlHAlqTOCAVz6P039Y/AGvO6xbC7f+9XftWlbbDcjKFcb - pQilkN9qtkdEH7TLayMAFOsgNvBlwF9+oj9w5PIk3veRTdBXI4GlHjhhzqGZKiRp - oP9HnycHHveyT+C33vuhQso5a3wcUNuvDVOixSqR4kvSt4UVWNK/KmEQmlWU1/m9 - ClIwrs8Q79q0xkGaSa0iuG60nvm7tZez9TFkxwIDAQABAoIBAHA5YkppQ7fJSm0D - wNDCHeyABNJWng23IuwZAOXVNxB1bjSOAv8yNgS4zaw/Hx5BnW8yi1lYZb+W0x2u - i5X7g91j0nkyEi5g88kJdFAGTsM5ok0BUwkHsEBjTUPIACanjGjya48lfBP0OGWK - LJU2Acbjda1aeUPFpPDXw/w6bieEthQwroq3DHCMnk6i9bsxgIOXeN04ij9XBmsH - KPCP2hAUnZSlx5febYfHK7/W95aJp22qa//eHS8cKQZCJ0+dQuZwLhlGosTFqLUm - qhPlt/b1EvPPY0cq5rtUc2W31L0YayVEHVOQx1fQIkH2VIUNbAS+bfVy+o6WCRk6 - s1XDhsECgYEA30tykVTN5LncY4eQIww2mW8v1j1EG6ngVShN3GuBTuXXaEOB8Duc - yT7yJt1ZhmaJwMk4agmZ1/f/ZXBtfLREGVzVvuwqRZ+LHbqIyhi0wQJA0aezPote - uTQnFn+IveHGtpQNDYGL/UgkexuCxbc2HOZG51JpunCK0TdtVfO/9OUCgYEA1TuS - 2WAXzNudRG3xd/4OgtkLD9AvfSvyjw2LkwqCMb3A5UEqw7vubk/xgnRvqrAgJRWo - jndgRrRnikHCavDHBO0GAO/kzrFRfw+e+r4jcLl0Yadke8ndCc7VTnx4wQCrMi5H - 7HEeRwaZONoj5PAPyA5X+N/gT0NNDA7KoQT45DsCgYBt+QWa6A5jaNpPNpPZfwlg - 9e60cAYcLcUri6cVOOk9h1tYoW7cdy+XueWfGIMf+1460Z90MfhP8ncZaY6yzUGA - 0EUBO+Tx10q3wIfgKNzU9hwgZZyU4CUtx668mOEqy4iHoVDwZu4gNyiobPsyDzKa - dxtSkDc8OHNV6RtzKpJOtQKBgFoRGcwbnLH5KYqX7eDDPRnj15pMU2LJx2DJVeU8 - ERY1kl7Dke6vWNzbg6WYzPoJ/unrJhFXNyFmXj213QsSvN3FyD1pFvp/R28mB/7d - hVa93vzImdb3wxe7d7n5NYBAag9+IP8sIJ/bl6i9619uTxwvgtUqqzKPuOGY9dnh - oce1AoGBAKZyZc/NVgqV2KgAnnYlcwNn7sRSkM8dcq0/gBMNuSZkfZSuEd4wwUzR - iFlYp23O2nHWggTkzimuBPtD7Kq4jBey3ZkyGye+sAdmnKkOjNILNbpIZlT6gK3z - fBaFmJGRJinKA+BJeH79WFpYN6SBZ/c3s5BusAbEU7kE5eInyazP - -----END RSA PRIVATE KEY----- + - key_file: keys/rsa_key - kid: "iv1aShae" key: | -----BEGIN EC PRIVATE KEY----- @@ -257,16 +232,24 @@ The following key types are supported: - ECDSA with the P-384 (`secp384r1`) curve - ECDSA with the K-256 (`secp256k1`) curve -Each entry must have a unique (and arbitrary) `kid`, plus the key itself. -The key can either be specified inline (with the `key` property), or loaded from a file (with the `key_file` property). +Each entry in the list corresponds to one signing key used by MAS. +The key can either be specified inline (with the `key` property), +or loaded from a file (with the `key_file` property). The following key formats are supported: - PKCS#1 PEM or DER-encoded RSA private key - PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not - SEC1 PEM or DER-encoded ECDSA private key +A [JWK Key ID] is automatically derived from each key. +To override this default, set `kid` to a custom value. +The `kid` can be any case-sensitive string value as long as it is unique to this list; +a key’s `kid` value must be stable across restarts. + For PKCS#8 encoded keys, the `password` or `password_file` properties can be used to decrypt the key. +[JWK Key ID]: + ## `passwords` Settings related to the local password database @@ -313,6 +296,12 @@ account: # This has no effect if password login is disabled. password_registration_enabled: false + # Whether self-service registrations require a valid email + # + # Defaults to `true` + # This has no effect if password registration is disabled. + password_registration_email_required: true + # Whether users are allowed to change their passwords # # Defaults to `true`. @@ -740,6 +729,13 @@ upstream_oauth2: # authorization request. #forward_login_hint: false + # What to do when receiving an OIDC Backchannel logout request. + # Possible values are: + # - `do_nothing` (default): do nothing, other than validating and logging the request + # - `logout_browser_only`: Only log out the MAS 'browser session' started by this OIDC session + # - `logout_all`: Log out all sessions started by this OIDC session, including MAS 'browser sessions' and client sessions + #on_backchannel_logout: do_nothing + # How user attributes should be mapped # # Most of those attributes have two main properties: @@ -770,6 +766,12 @@ upstream_oauth2: #action: force #template: "{{ user.preferred_username }}" + # How to handle when localpart already exists. + # Possible values are (default: fail): + # - `add` : Adds the upstream account link to the existing user, regardless of whether there is an existing link or not. + # - `fail` : Fails the upstream OAuth 2.0 login. + #on_conflict: fail + # The display name is the user's display name. displayname: #action: suggest @@ -795,6 +797,37 @@ upstream_oauth2: #template: "@{{ user.preferred_username }}" ``` +## `branding` + +Configuration section for tweaking the branding of the service. + +```yaml +branding: + # A human-readable name. Defaults to the server's address. + #service_name: + + # Link to a privacy policy, displayed in the footer of web pages and + # emails. It is also advertised to clients through the `op_policy_uri` + # OIDC provider metadata. + #policy_uri: + + # Link to a terms of service document, displayed in the footer of web + # pages and emails. It is also advertised to clients through the + # `op_tos_uri` OIDC provider metadata. + # + # This also adds a mandatory checkbox during registration. The value of + # this config item will be stored in the `user_terms` table to indicate + # which ToS document the user accepted. Note that currently changing this + # value will not force existing users to re-accept terms. + #tos_uri: + + # Legal imprint, displayed in the footer in the footer of web pages and emails. + #imprint: + + # Logo displayed in some web pages. + #logo_uri: +``` + ## `experimental` Settings that may change or be removed in future versions. diff --git a/docs/reference/scopes.md b/docs/reference/scopes.md index 78a261c91..fcb76d352 100644 --- a/docs/reference/scopes.md +++ b/docs/reference/scopes.md @@ -4,9 +4,8 @@ The [default policy](../topics/policy.md#authorization-requests) shipped with MA - [`openid`](#openid) - [`email`](#email) - - [`urn:matrix:org.matrix.msc2967.client:api:*`](#urnmatrixorgmatrixmsc2967clientapi) - - [`urn:matrix:org.matrix.msc2967.client:device:[device id]`](#urnmatrixorgmatrixmsc2967clientdevicedevice-id) - - [`urn:matrix:org.matrix.msc2967.client:guest`](#urnmatrixorgmatrixmsc2967clientguest) + - [`urn:matrix:client:api:*`](#urnmatrixclientapi) + - [`urn:matrix:client:device:[device id]`](#urnmatrixclientdevicedevice-id) - [`urn:synapse:admin:*`](#urnsynapseadmin) - [`urn:mas:admin`](#urnmasadmin) - [`urn:mas:graphql:*`](#urnmasgraphql) @@ -33,13 +32,13 @@ The default policy allows any client and any user to request this scope. Those scopes are specific to the Matrix protocol and are part of [MSC2967]. -### `urn:matrix:org.matrix.msc2967.client:api:*` +### `urn:matrix:client:api:*` This scope grants access to the full Matrix client-server API. The default policy allows any client and any user to request this scope. -### `urn:matrix:org.matrix.msc2967.client:device:[device id]` +### `urn:matrix:client:device:[device id]` This scope sets the device ID of the session, where `[device id]` is the device ID of the session. Currently, MAS only allows the following characters in the device ID: `a-z`, `A-Z`, `0-9` and `-`. @@ -49,15 +48,6 @@ There can only be one device ID in the scope list of a session. The default policy allows any client and any user to request this scope. -### `urn:matrix:org.matrix.msc2967.client:guest` - -This scope grants access to a restricted set of endpoints that are available to guest users. -It is mutually exclusive with the `urn:matrix:org.matrix.msc2967.client:api:*` scope. - -Note that MAS doesn't yet implement any special semantic around guest users, but this scope is reserved for future use. - -The default policy allows any client and any user to request this scope. - ## Synapse-specific scopes MAS also supports one Synapse-specific scope, which aren't formally defined in any specification. @@ -67,7 +57,7 @@ MAS also supports one Synapse-specific scope, which aren't formally defined in a This scope grants access to the [Synapse admin API]. Because of how Synapse works for now, this scope by itself isn't sufficient to access the admin API. -A session wanting to access the admin API also needs to have the `urn:matrix:org.matrix.msc2967.client:api:*` scope. +A session wanting to access the admin API also needs to have the `urn:matrix:client:api:*` scope. The default policy doesn't allow everyone to request this scope. It allows: diff --git a/docs/setup/homeserver.md b/docs/setup/homeserver.md index 614d4b190..ead3f8a17 100644 --- a/docs/setup/homeserver.md +++ b/docs/setup/homeserver.md @@ -1,74 +1,46 @@ # Homeserver configuration The `matrix-authentication-service` is designed to be run alongside a Matrix homeserver. -It currently only supports [Synapse](https://github.com/element-hq/synapse) through the experimental OAuth delegation feature. +It currently only supports [Synapse](https://github.com/element-hq/synapse) version 1.136.0 or later. The authentication service needs to be able to call the Synapse admin API to provision users through a shared secret, and Synapse needs to be able to call the service to verify access tokens using the OAuth 2.0 token introspection endpoint. -## Provision a client for the Homeserver to use - -In the [`clients`](../reference/configuration.md#clients) section of the configuration file, add a new client with the following properties: - - - `client_id`: a unique identifier for the client. It must be a valid [ULID](https://github.com/ulid/spec), and it happens that `0000000000000000000SYNAPSE` is a valid ULID. - - `client_auth_method`: set to `client_secret_basic`. Other methods are possible, but this is the easiest to set up. - - `client_secret`: a shared secret used for the homeserver to authenticate - -```yaml -clients: - - client_id: 0000000000000000000SYNAPSE - client_auth_method: client_secret_basic - client_secret: "SomeRandomSecret" -``` - -**Don't forget to sync the configuration file** with the database after adding the client, using the [`config sync`](../reference/cli/config.md#config-sync---prune---dry-run) command. - ## Configure the connection to the homeserver In the [`matrix`](../reference/configuration.md#matrix) section of the configuration file, add the following properties: + - `kind`: the type of homeserver to connect to, currently only `synapse` is supported - `homeserver`: corresponds to the `server_name` in the Synapse configuration file - - `secret`: a shared secret the service will use to call the homeserver admin API + - `secret`: a shared secret the service will use to call the homeserver MAS API - `endpoint`: the URL to which the homeserver is accessible from the service ```yaml matrix: - homeserver: localhost:8008 - secret: "AnotherRandomSecret" + kind: synapse + homeserver: example.com endpoint: "http://localhost:8008" + secret: "AVeryRandomSecretPleaseUseSomethingSecure" + # Alternatively, using a file: + #secret_path: /path/to/secret.txt ``` ## Configure the homeserver to delegate authentication to the service -Set up the delegated authentication feature in the Synapse configuration in the `experimental_features` section: +Set up the delegated authentication feature **in the Synapse configuration** in the `matrix_authentication_service` section: ```yaml -experimental_features: - msc3861: - enabled: true - - # Synapse will call `{issuer}/.well-known/openid-configuration` to get the OIDC configuration - issuer: http://localhost:8080/ - - # Matches the `client_id` in the auth service config - client_id: 0000000000000000000SYNAPSE - # Matches the `client_auth_method` in the auth service config - client_auth_method: client_secret_basic - # Matches the `client_secret` in the auth service config - client_secret: "SomeRandomSecret" - - # Matches the `matrix.secret` in the auth service config - admin_token: "AnotherRandomSecret" - - # URL to advertise to clients where users can self-manage their account - # Defaults to the URL advertised by MAS, e.g. `https://{public_mas_domain}/account/` - #account_management_url: "http://localhost:8080/account/" - - # URL which Synapse will use to introspect access tokens - # Defaults to the URL advertised by MAS, e.g. `https://{public_mas_domain}/oauth2/introspect` - # This is useful to override if Synapse has a way to call the auth service's - # introspection endpoint directly, skipping intermediate reverse proxies - #introspection_endpoint: "http://localhost:8080/oauth2/introspect" +matrix_authentication_service: + enabled: true + endpoint: http://localhost:8080/ + secret: "AVeryRandomSecretPleaseUseSomethingSecure" + # Alternatively, using a file: + #secret_file: /path/to/secret.txt ``` +The `endpoint` property should be set to the URL of the authentication service. +This can be an internal URL, to avoid unnecessary round-trips. + +The `secret` property must match in both the Synapse configuration and the Matrix Authentication Service configuration. + ## Set up the compatibility layer The service exposes a compatibility layer to allow legacy clients to authenticate using the service. @@ -81,3 +53,17 @@ The following Matrix Client-Server API endpoints need to be handled by the authe - [`/_matrix/client/*/refresh`](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3refresh) See the [reverse proxy configuration](./reverse-proxy.md) guide for more information. + + +## Migrating from the experimental MSC3861 feature + +If you are migrating from the experimental MSC3861 feature in Synapse, you will need to migrate the `experimental_features.msc3861` section of the Synapse configuration to the `matrix_authentication_service` section. + +To do so, you need to: + + - Remove the `experimental_features.msc3861` section from the Synapse configuration + - Add the `matrix_authentication_service` section to the Synapse configuration with: + - `enabled: true` + - `endpoint` set to the URL of the authentication service + - `secret` set to the same secret as the `admin_token` that was set in the `msc3861` section + - Optionally, remove the client provisioned for Synapse in the `clients` section of the MAS configuration diff --git a/docs/setup/sso.md b/docs/setup/sso.md index 0f3994825..3442d06bd 100644 --- a/docs/setup/sso.md +++ b/docs/setup/sso.md @@ -24,6 +24,7 @@ The general configuration usually goes as follows: - `response_type`: `code` - `response_mode`: `query` - `grant_type`: `authorization_code` + - (optional) `backchannel_logout_uri`: `https:///upstream/backchannel-logout/` - fill the `upstream_oauth2` section of the configuration file with the following parameters: - `providers`: - `id`: the previously generated ULID @@ -65,6 +66,30 @@ The template has the following variables available: - `user`: an object which contains the claims from both the `id_token` and the `userinfo` endpoint - `extra_callback_parameters`: an object with the additional parameters the provider sent to the redirect URL + +## Allow linking existing user accounts + +The authentication service supports linking external provider identities to existing local user accounts. + +To enable this behavior, the following option must be explicitly set in the provider configuration: + +```yaml +claims_imports: + localpart: + on_conflict: add +``` +`on_conflict` configuration is specific to `localpart` claim_imports, it can be either: +* `add` : when a user authenticates with the provider for the first time, the system checks whether a local user already exists with a `localpart` matching the attribute mapping `localpart` , _by default `{{ user.preferred_username }}`_. If a match is found, the external identity is linked to the existing local account. +* `fail` *(default)* : fails the sso login. + +To enable this option, the `localpart` mapping must be set to either `force` or `require`. + +> ⚠️ **Security Notice** +> Enabling this option can introduce a risk of account takeover. +> +> To mitigate this risk, ensure that this option is only enabled for identity providers where you can guarantee that the attribute mapping `localpart` will reliably and uniquely correspond to the intended local user account. + + ## Multiple providers behaviour Multiple authentication methods can be configured at the same time, in which case the authentication service will let the user choose which one to use. @@ -73,6 +98,25 @@ In such cases, the `human_name` parameter of the provider configuration is used If there is only one upstream provider configured and the local password database is disabled ([`passwords.enabled`](../reference/configuration.md#passwords) is set to `false`), the authentication service will automatically trigger an authorization flow with this provider. +## Backchannel logout + +The service supports receiving [OpenID Connect Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) requests. +Those are notifications from the upstream provider that the user has logged out of the provider. + +The backchannel logout URI must be configured in the provider as `https:///upstream/backchannel-logout/`, where `` is the `id` of the provider. + +By default, the authentication service will not perform any action when receiving a backchannel logout request. +The [`on_backchannel_logout`](../reference/configuration.md#upstream_oauth2) option can be used to configure what to do when receiving a backchannel logout request. + +Possible values are: + + - `do_nothing`: Do nothing, other than validating and logging the request + - `logout_browser_only`: Only log out the MAS 'browser session' started by this OIDC session + - `logout_all`: Log out all sessions started by this OIDC session, including MAS 'browser sessions' and client sessions + +One important caveat is that `logout_all` will log out all sessions started by this upstream OIDC session, including 'remote' ones done through the Device Code flow. +Concretely, this means that if QR-code login is used to log in on a phone from a laptop, when MAS receives a backchannel logout request from the upstream provider for the laptop, MAS will also log out the session on the phone. + ## Sample configurations This section contains sample configurations for popular OIDC providers. @@ -93,12 +137,11 @@ upstream_oauth2: response_mode: "form_post" token_endpoint_auth_method: "sign_in_with_apple" sign_in_with_apple: - # Only one of the below should be filled for the private key private_key_file: "" # TO BE FILLED private_key: | # TO BE FILLED # - + team_id: "" # TO BE FILLED key_id: "" # TO BE FILLED claims_imports: @@ -386,6 +429,9 @@ Follow the [Getting Started Guide](https://www.keycloak.org/guides) to install K | Client Protocol | `openid-connect` | | Access Type | `confidential` | | Valid Redirect URIs | `https:///upstream/callback/` | + | Front channel logout | `Off` | + | Backchannel logout URL | `https:///upstream/backchannel-logout/` | + | Backchannel logout session required | `On` | 5. Click `Save` 6. On the Credentials tab, update the fields: @@ -554,4 +600,4 @@ To use a Rauthy-supported [Ephemeral Client](https://sebadob.github.io/rauthy/wo "access_token_signed_response_alg": "RS256", "id_token_signed_response_alg": "RS256" } -``` \ No newline at end of file +``` diff --git a/docs/topics/access-token.md b/docs/topics/access-token.md new file mode 100644 index 000000000..16acc4a10 --- /dev/null +++ b/docs/topics/access-token.md @@ -0,0 +1,31 @@ +# Get an access token + +The [Matrix Authentication Service repository contains a simple shell script](https://github.com/element-hq/matrix-authentication-service/blob/main/misc/device-code-grant.sh) to interactively get an access token with arbitrary scopes. +It requires `sh`, `jq` and `curl` to be installed. +This can be run from anywhere, not necessarily from the host where MAS is running. + +```sh +sh ./misc/device-code-grant.sh [synapse-url] ... +``` + +This will prompt you to open a URL in your browser, finish the authentication flow, and print the access and refresh tokens. + +This can be used to get access to the MAS admin API: + +```sh +sh ./misc/device-code-grant.sh https://synapse.example.com/ urn:mas:admin +``` + +Or to the Synapse admin API: + +```sh +sh ./misc/device-code-grant.sh https://synapse.example.com/ urn:matrix:org.matrix.msc2967.client:api:* urn:synapse:admin:* +``` + +Or even both at the same time: + +```sh +sh ./misc/device-code-grant.sh https://synapse.example.com/ urn:matrix:org.matrix.msc2967.client:api:* urn:mas:admin urn:synapse:admin:* +``` + +Note that the token will only be valid for a short time (5 minutes by default) and needs to be revoked manually from the MAS user interface. diff --git a/docs/topics/admin-api.md b/docs/topics/admin-api.md index 75d5e2b0a..8aa992c76 100644 --- a/docs/topics/admin-api.md +++ b/docs/topics/admin-api.md @@ -46,7 +46,9 @@ If admin API is enabled, MAS will also serve the specification at `/api/spec.jso ## Authentication -All requests to the admin API are gated using access tokens obtained using OAuth 2.0 grants. +All requests to the admin API are gated either using access tokens obtained using OAuth 2.0 grants, +or using personal access tokens (which must currently be issued through the Admin API). + They must have the [`urn:mas:admin`](../reference/scopes.md#urnmasadmin) scope. ### User-interactive tools diff --git a/docs/topics/authorization.md b/docs/topics/authorization.md index d2d7ab29c..1bdeb8207 100644 --- a/docs/topics/authorization.md +++ b/docs/topics/authorization.md @@ -129,6 +129,33 @@ It may also be used in the future as a foundation for a new Application Service This works by presenting the client credentials to get back an access token. The simplest type of client credentials is a client ID and client secret pair, but MAS also supports client authentication with a JWT ([RFC 7523]), which is a robust way to authenticate clients without a shared secret. +## Personal sessions (personal access tokens) + +Personal access tokens are a credential that can be issued to give access to a user, +with predefined scopes and a predefined expiry time. +Either before or after expiry, the owner of the token can regenerate it, which produces a new +access token with the same scopes but a new expiry time. + +Personal access tokens are intended to fulfill two basic use cases: + +1. an easy way to obtain a clean token for your own user, for use in automation and scripts; +2. a way to obtain a token for administrative access of another user, either for ad-hoc administrative operations or to set up a bot or similar service. + +In the future, users will be able to create their own personal access tokens, but this is currently not implemented +so (1) is currently not supported. + +For now, personal access tokens must be created, regenerated and revoked by administrators through the [Admin API], satisfying use case (2). +[Element Admin](https://github.com/element-hq/element-admin), available by default in Element Server Suite, can be used to do this interactively. +You can also use the online beta deployment at [admin-beta.element.dev](https://admin-beta.element.dev/). + +### Validity + +Personal sessions can be used so long as: + +- the owner (creator) of the token is still an active and unlocked user (or static OAuth 2 client); and +- the actor (target user, or user being controlled by the token) has not been deactivated. Though the actor is allowed to be locked. + + [MSC4108]: https://github.com/matrix-org/matrix-spec-proposals/pull/4108 [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749 [RFC 7523]: https://datatracker.ietf.org/doc/html/rfc7523 @@ -140,3 +167,4 @@ The simplest type of client credentials is a client ID and client secret pair, b [`urn:synapse:admin:*`]: ../reference/scopes.md#urnsynapseadmin [`urn:mas:graphql:*`]: ../reference/scopes.md#urnmasgraphql [`urn:mas:admin`]: ../reference/scopes.md#urnmasadmin +[Admin API]: ./admin-api.md diff --git a/docs/topics/policy.md b/docs/topics/policy.md index 9155c2211..7ea1c2c93 100644 --- a/docs/topics/policy.md +++ b/docs/topics/policy.md @@ -24,11 +24,10 @@ As such, they usually can be bypassed through the admin API or the CLI if needed ### User attributes -The policy is evaluated in three different scenarios: +The policy is evaluated in the following different scenarios: - - [`register.rego`]: During user registration, either with password credentials or with an upstream OAuth 2.0 provider. This calls the [`email.rego`] and [`password.rego`] policies as well. + - [`register.rego`]: During user registration, either with password credentials or with an upstream OAuth 2.0 provider. This calls the [`email.rego`] policy as well. - [`email.rego`]: When a user adds a new email address to their account. - - [`password.rego`]: When a user changes their password. ### Client registration @@ -69,8 +68,7 @@ This is especially important as in the future it will make it possible to implem To understand the authorization process and how sessions are created, refer to the [authorization and sessions](./authorization.md) section. -[`register.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/register.rego -[`email.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/email.rego -[`password.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/password.rego -[`client_registration.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/client_registration.rego -[`authorization_grant.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/authorization_grant.rego +[`register.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/register/register.rego +[`email.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/email/email.rego +[`client_registration.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/client_registration/client_registration.rego +[`authorization_grant.rego`]: https://github.com/element-hq/matrix-authentication-service/blob/main/policies/authorization_grant/authorization_grant.rego diff --git a/frontend/.editorconfig b/frontend/.editorconfig deleted file mode 100644 index 70fefa646..000000000 --- a/frontend/.editorconfig +++ /dev/null @@ -1,11 +0,0 @@ -root = true - -[*] -charset=utf-8 -end_of_line = lf - -[*.{ts,tsx,js,cjs,mjs,css,json,graphql}] -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true diff --git a/frontend/.gitignore b/frontend/.gitignore index 5b51a6b97..bef0bf155 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,8 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + /node_modules /dist /coverage diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index acfd7491f..b4ffa1976 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,15 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: ["../{src,stories}/**/*.stories.@(js|jsx|ts|tsx)"], - addons: ["storybook-react-i18next", "@storybook/addon-docs"], + addons: ["@storybook/addon-docs"], framework: "@storybook/react-vite", diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 1f0b019a5..7ba9e4218 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,18 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -import type { - ArgTypes, - Decorator, - Parameters, - Preview, -} from "@storybook/react-vite"; +import type { Decorator, Preview } from "@storybook/react-vite"; import { TooltipProvider } from "@vector-im/compound-web"; import { initialize, mswLoader } from "msw-storybook-addon"; -import { useLayoutEffect } from "react"; +import { useEffect, useLayoutEffect } from "react"; +import { I18nextProvider } from "react-i18next"; import "../src/shared.css"; import i18n, { setupI18n } from "../src/i18n"; import { DummyRouter } from "../src/test-utils/router"; @@ -31,37 +27,12 @@ initialize( setupI18n(); -export const parameters: Parameters = { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -}; - -export const globalTypes = { - theme: { - name: "Theme", - defaultValue: "system", - description: "Global theme for components", - toolbar: { - icon: "circlehollow", - title: "Theme", - items: [ - { title: "System", value: "system", icon: "browser" }, - { title: "Light", value: "light", icon: "sun" }, - { title: "Light (high contrast)", value: "light-hc", icon: "sun" }, - { title: "Dark", value: "dark", icon: "moon" }, - { title: "Dark (high contrast)", value: "dark-hc", icon: "moon" }, - ], - }, - }, -} satisfies ArgTypes; - -const allThemesClasses = globalTypes.theme.toolbar.items.map( - ({ value }) => `cpd-theme-${value}`, -); +const allThemesClasses = [ + "cpd-theme-light", + "cpd-theme-light-hc", + "cpd-theme-dark", + "cpd-theme-dark-hc", +]; const ThemeSwitcher: React.FC<{ theme: string; @@ -86,6 +57,27 @@ const withThemeProvider: Decorator = (Story, context) => { ); }; +const LocaleSwitcher: React.FC<{ + locale: string; +}> = ({ locale }) => { + useEffect(() => { + i18n.changeLanguage(locale); + }, [locale]); + + return null; +}; + +const withI18nProvider: Decorator = (Story, context) => { + return ( + <> + + + + + + ); +}; + const withDummyRouter: Decorator = (Story, _context) => { return ( @@ -102,28 +94,58 @@ const withTooltipProvider: Decorator = (Story, _context) => { ); }; -export const decorators: Decorator[] = [ - withThemeProvider, - withDummyRouter, - withTooltipProvider, -]; - -const locales = Object.fromEntries( - localazyMetadata.languages.map(({ language, name, localizedName }) => [ - language, - `${localizedName} (${name})`, - ]), -); - const preview: Preview = { + loaders: [mswLoader], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, + decorators: [ + withI18nProvider, + withThemeProvider, + withDummyRouter, + withTooltipProvider, + ], + globalTypes: { + theme: { + name: "Theme", + description: "Global theme for components", + toolbar: { + icon: "circlehollow", + title: "Theme", + items: [ + { title: "System", value: "system", icon: "browser" }, + { title: "Light", value: "light", icon: "sun" }, + { title: "Light (high contrast)", value: "light-hc", icon: "sun" }, + { title: "Dark", value: "dark", icon: "moon" }, + { title: "Dark (high contrast)", value: "dark-hc", icon: "moon" }, + ], + }, + }, + + locale: { + name: "Locale", + description: "Locale for the app", + toolbar: { + title: "Language", + icon: "globe", + items: localazyMetadata.languages.map( + ({ language, localizedName, name }) => ({ + title: `${localizedName} (${name})`, + value: language, + }), + ), + }, + }, + }, initialGlobals: { locale: localazyMetadata.baseLocale, - locales, + theme: "system", }, - parameters: { - i18n, - }, - loaders: [mswLoader], tags: ["autodocs"], }; diff --git a/frontend/.storybook/public/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js index 84491eb36..2f658e919 100644 --- a/frontend/.storybook/public/mockServiceWorker.js +++ b/frontend/.storybook/public/mockServiceWorker.js @@ -5,24 +5,23 @@ * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. - * - Please do NOT serve this file on production. */ -const PACKAGE_VERSION = '2.8.7' -const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' +const PACKAGE_VERSION = '2.11.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() -self.addEventListener('install', function () { +addEventListener('install', function () { self.skipWaiting() }) -self.addEventListener('activate', function (event) { +addEventListener('activate', function (event) { event.waitUntil(self.clients.claim()) }) -self.addEventListener('message', async function (event) { - const clientId = event.source.id +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { return @@ -72,11 +71,6 @@ self.addEventListener('message', async function (event) { break } - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId) - break - } - case 'CLIENT_CLOSED': { activeClientIds.delete(clientId) @@ -94,69 +88,92 @@ self.addEventListener('message', async function (event) { } }) -self.addEventListener('fetch', function (event) { - const { request } = event +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() // Bypass navigation requests. - if (request.mode === 'navigate') { + if (event.request.mode === 'navigate') { return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). + // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { return } - // Generate unique request ID. const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) }) -async function handleRequest(event, requestId) { +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - ;(async function () { - const responseClone = response.clone() + const serializedRequest = await serializeRequest(requestCloneForEvents) - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { type: responseClone.type, status: responseClone.status, statusText: responseClone.statusText, - body: responseClone.body, headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, }, }, - [responseClone.body], - ) - })() + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) } return response } -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ async function resolveMainClient(event) { const client = await self.clients.get(event.clientId) @@ -184,12 +201,17 @@ async function resolveMainClient(event) { }) } -async function getResponse(event, client, requestId) { - const { request } = event - +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = request.clone() + const requestClone = event.request.clone() function passthrough() { // Cast the request headers to a new Headers instance @@ -230,29 +252,18 @@ async function getResponse(event, client, requestId) { } // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer() + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { type: 'REQUEST', payload: { id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, + interceptedAt: requestInterceptedAt, + ...serializedRequest, }, }, - [requestBuffer], + [serializedRequest.body], ) switch (clientMessage.type) { @@ -268,6 +279,12 @@ async function getResponse(event, client, requestId) { return passthrough() } +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() @@ -280,14 +297,18 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data) } - client.postMessage( - message, - [channel.port2].concat(transferrables.filter(Boolean)), - ) + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) }) } -async function respondWithMock(response) { +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { // Setting response status code to 0 is a no-op. // However, when responding with a "Response.error()", the produced Response // instance will have status code set to 0. Since it's not possible to create @@ -305,3 +326,24 @@ async function respondWithMock(response) { return mockedResponse } + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/frontend/codegen.ts b/frontend/codegen.ts index 76a256465..2020638e0 100644 --- a/frontend/codegen.ts +++ b/frontend/codegen.ts @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { CodegenConfig } from "@graphql-codegen/cli"; diff --git a/frontend/i18next-parser.config.ts b/frontend/i18next-parser.config.ts index 4b003ccbd..1fa452605 100644 --- a/frontend/i18next-parser.config.ts +++ b/frontend/i18next-parser.config.ts @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { UserConfig } from "i18next-parser"; diff --git a/frontend/index.html b/frontend/index.html index 789ad52a7..e482ec272 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,9 +1,9 @@ diff --git a/frontend/knip.config.ts b/frontend/knip.config.ts index a6a4c76cf..b5e382961 100644 --- a/frontend/knip.config.ts +++ b/frontend/knip.config.ts @@ -1,22 +1,15 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { KnipConfig } from "knip"; export default { - entry: [ - "src/main.tsx", - "src/swagger.ts", - "src/routes/*", - "i18next-parser.config.ts", - ], + entry: ["src/main.tsx", "src/swagger.ts", "src/routes/*"], ignore: ["src/gql/*", "src/routeTree.gen.ts", ".storybook/locales.ts"], ignoreDependencies: [ // This is used by the tailwind PostCSS plugin, but not detected by knip "postcss-nesting", - // We're using @storybook/addon-essentials to simplify upgrades, but knip doesn't detect them - "@storybook/addon-*", ], } satisfies KnipConfig; diff --git a/frontend/locales/cs.json b/frontend/locales/cs.json index e1a843475..8613ad33e 100644 --- a/frontend/locales/cs.json +++ b/frontend/locales/cs.json @@ -147,7 +147,7 @@ }, "nav": { "devices": "Zařízení", - "plan": "Plan", + "plan": "Plán", "profile": "Profil", "sessions": "Relace", "settings": "Nastavení" diff --git a/frontend/locales/da.json b/frontend/locales/da.json index 19cad7504..0529125ff 100644 --- a/frontend/locales/da.json +++ b/frontend/locales/da.json @@ -146,7 +146,7 @@ }, "nav": { "devices": "Enheder", - "plan": "Plan", + "plan": "Abonnementsordning", "profile": "Profil", "sessions": "Sessioner", "settings": "Indstillinger" @@ -333,7 +333,7 @@ "delete_button_confirmation_modal": { "action": "Slet e-mail", "body": "Slet denne e-mail?", - "incorrect_password": "Forkert adgangskode, prøv igen", + "incorrect_password": "Forkert adgangskode, prøv venligst igen", "password_confirmation": "Bekræft adgangskoden til din konto for at slette denne e-mail-adresse" }, "delete_button_title": "Fjern e-mailadresse", diff --git a/frontend/locales/de.json b/frontend/locales/de.json index b5bcfe437..75c8493ed 100644 --- a/frontend/locales/de.json +++ b/frontend/locales/de.json @@ -5,7 +5,7 @@ "clear": "Löschen", "close": "Schließen", "collapse": "Zusammenbruch", - "confirm": " Bestätigen Sie", + "confirm": "Bestätigen", "continue": "Weiter", "edit": "Bearbeiten", "expand": "Erweitern", @@ -40,16 +40,16 @@ "account_password": "Kontokennwort", "contact_info": "Kontaktinformation", "delete_account": { - "alert_description": "Dieses Konto wird dauerhaft gelöscht und Sie haben keinen Zugriff mehr auf Ihre Nachrichten.", - "alert_title": "Sie sind dabei, alle Ihre Daten zu verlieren", + "alert_description": "Dieses Konto wird dauerhaft entfernt und du hast keinen Zugriff mehr auf deine Nachrichten.", + "alert_title": "Du bist kurz davor, alle deine Daten zu verlieren.", "button": "Account löschen", - "dialog_description": "Bestätigen Sie, dass Sie Ihr Konto löschen möchten:\n\nSie können Ihr Konto nicht reaktivieren\nSie können sich nicht mehr anmelden\nNiemand kann Ihren Benutzernamen (MXID) wieder verwenden, auch Sie nicht.\nSie verlassen alle Räume und Direktnachrichten, in denen Sie sich befinden\nSie werden vom Identitätsserver entfernt und niemand kann Sie mit Ihrer E-Mail-Adresse oder Telefonnummer finden\n\nIhre alten Nachrichten sind für Empfänger weiterhin sichtbar. Möchten Sie Ihre gesendeten Nachrichten vor zukünftigen Chatroom-Besuchern verbergen?", + "dialog_description": "Bestätige, dass du dein Konto löschen möchtest:\n\nDu kannst dein Konto nicht reaktivieren\nDu kannst dich nicht mehr anmelden\nNiemand kann deinen Benutzernamen (MXID) wieder verwenden, auch du nicht.\nDu verlässt alle Gruppen und Chats\nDu wirst vom Identitätsserver entfernt und niemand kann dich mit deiner E-Mail-Adresse oder Telefonnummer finden\n\nDeine alten Nachrichten sind für Empfänger weiterhin sichtbar. Möchtest du deine gesendeten Nachrichten vor zukünftigen Gruppen-Besuchern verbergen?", "dialog_title": "Dieses Konto löschen?", "erase_checkbox_label": "Ja, alle meine Nachrichten vor neuen Mitgliedern verbergen", - "incorrect_password": "Falsches Passwort, bitte versuchen Sie es erneut", - "mxid_label": "Bestätigen Sie Ihre Matrix-ID ({{ mxid }})", - "mxid_mismatch": "Dieser Wert stimmt nicht mit Ihrer Matrix-ID überein", - "password_label": "Geben Sie Ihr Passwort ein, um fortzufahren" + "incorrect_password": "Falsches Passwort, versuch's nochmal", + "mxid_label": "Bestätige deine Matrix-ID ({{ mxid }})", + "mxid_mismatch": "Dieser Wert passt nicht zu deiner Matrix-ID.", + "password_label": "Gib dein Passwort ein, um weiterzumachen" }, "edit_profile": { "display_name_help": "Dies ist der öffentliche Nutzername.", @@ -79,7 +79,7 @@ "title": "Diese E-Mailadresse existiert bereits" }, "email_exists_error": "Die eingegebene E-Mail-Adresse ist diesem Konto bereits zugeordnet", - "email_field_help": "Fügen Sie eine alternative E-Mail-Adresse hinzu, mit der Sie auf dieses Konto zugreifen können.", + "email_field_help": "Gib eine alternative E-Mail-Adresse an, mit der du auf dieses Konto zugreifen kannst.", "email_field_label": "E-Mail-Adresse hinzufügen", "email_in_use_error": "Die eingegebene E-Mail wird bereits verwendet", "email_invalid_alert": { @@ -87,8 +87,8 @@ "title": "Ungültige Email-Adresse" }, "email_invalid_error": "Die eingegebene E-Mail-Adresse ist ungültig", - "incorrect_password_error": "Falsches Passwort, bitte versuchen Sie es erneut", - "password_confirmation": "Bestätigen Sie Ihr Kontopasswort, um diese E-Mail-Adresse hinzuzufügen" + "incorrect_password_error": "Falsches Passwort, versuch's nochmal", + "password_confirmation": "Bestätige dein Passwort, um diese E-Mail-Adresse hinzuzufügen." }, "app_sessions_list": { "error": "App-Sitzungen konnten nicht geladen werden", @@ -103,8 +103,8 @@ "body:other": "{{count}} aktive Sitzungen", "heading": "Browser", "no_active_sessions": { - "default": "Sie sind bei keinem Webbrowser angemeldet.", - "inactive_90_days": "Alle Ihre Sitzungen waren in den letzten 90 Tagen aktiv." + "default": "Du bist in keinem Webbrowser angemeldet.", + "inactive_90_days": "Alle deine Sitzungen waren in den letzten 90 Tagen aktiv." }, "view_all_button": "Alle anzeigen" }, @@ -125,19 +125,19 @@ "heading": "Die E-Mail-Adresse {{email}} wird bereits verwendet." }, "end_session_button": { - "confirmation_modal_title": "Sind Sie sicher, dass Sie diese Sitzung abmelden möchten?", + "confirmation_modal_title": "Möchtest du diese Sitzung wirklich beenden?", "text": "Gerät entfernen" }, "error": { "hideDetails": "Details ausblenden", "showDetails": "Details anzeigen", - "subtitle": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut", + "subtitle": "Ein unerwarteter Fehler ist aufgetreten, bitte versuch's nochmal.", "title": "Etwas ist schief gelaufen" }, "error_boundary_title": "Etwas ist schief gelaufen", "errors": { "field_required": "Dieses Feld ist ein Pflichtfeld", - "rate_limit_exceeded": "Sie haben in kurzer Zeit zu viele Anfragen gestellt. Bitte warten Sie einige Minuten und versuchen Sie es erneut." + "rate_limit_exceeded": "Du hast in kurzer Zeit zu viele Anfragen gestellt. Warte bitte ein paar Minuten und versuch's nochmal." }, "last_active": { "active_date": "Aktiv {{relativeDate}}", @@ -146,13 +146,13 @@ }, "nav": { "devices": "Geräte", - "plan": "Plan", + "plan": "Abo", "profile": "Profil", "sessions": "Sitzungen", "settings": "Einstellungen" }, "not_found_alert_title": "Nicht gefunden.", - "not_logged_in_alert": "Sie sind nicht angemeldet.", + "not_logged_in_alert": "Du bist nicht angemeldet.", "oauth2_client_detail": { "details_title": "Geräte Information", "id": "Client-ID", @@ -172,15 +172,15 @@ "current_password_label": "Aktuelles Passwort", "failure": { "description": { - "account_locked": "Ihr Konto ist gesperrt und kann derzeit nicht wiederhergestellt werden. Wenn dies unerwartet auftritt, wenden Sie sich bitte an Ihren Serveradministrator.", - "expired_recovery_ticket": "Der Wiederherstellungslink ist abgelaufen. Bitte starten Sie den Kontowiederherstellungsprozess erneut von Anfang an.", - "invalid_new_password": "Das neue Passwort, das Sie gewählt haben, ist ungültig; es entspricht möglicherweise nicht der konfigurierten Sicherheitsrichtlinie.", - "no_current_password": "Sie haben kein aktuelles Passwort.", - "no_such_recovery_ticket": "Der Wiederherstellungslink ist ungültig. Wenn Sie den Link aus der Wiederherstellungs-E-Mail kopiert haben, überprüfen Sie bitte, ob der vollständige Link kopiert wurde.", + "account_locked": "Dein Konto ist gesperrt und kann im Moment nicht wiederhergestellt werden. Wenn du das nicht erwartet hast, wende dich bitte an deinen Server-Admin.", + "expired_recovery_ticket": "Der Link zur Kontowiederherstellung ist abgelaufen. Bitte fang den Prozess noch mal von vorne an.", + "invalid_new_password": "Das neue Passwort, das du gewählt hast, ist ungültig; es entspricht möglicherweise nicht den Sicherheitsrichtlinien.", + "no_current_password": "Du hast kein aktuelles Passwort.", + "no_such_recovery_ticket": "Der Link zum Wiederherstellen ist nicht gültig. Wenn du den Link aus der E-Mail zum Wiederherstellen kopiert hast, schau bitte nach, ob du den vollständigen Link kopiert hast.", "password_changes_disabled": "Passwortänderungen sind deaktiviert.", "recovery_ticket_already_used": "Der Wiederherstellungslink wurde bereits verwendet. Er kann nicht erneut verwendet werden.", - "unspecified": "Dies könnte ein vorübergehendes Problem sein. Bitte versuchen Sie es später erneut. Wenn das Problem weiterhin besteht, wenden Sie sich bitte an Ihren Serveradministrator.", - "wrong_password": "Das Passwort, das Sie als Ihr aktuelles Passwort eingegeben haben, ist falsch. Bitte versuchen Sie es erneut." + "unspecified": "Das könnte ein vorübergehendes Problem sein, also versuch's später nochmal. Wenn das Problem weiterhin besteht, wende dich bitte an deinen Server-Admin.", + "wrong_password": "Das Passwort, das du als dein aktuelles Passwort angegeben hast, ist falsch. Versuch's bitte nochmal." }, "title": "Aktualisierung des Passworts fehlgeschlagen" }, @@ -188,25 +188,25 @@ "new_password_label": "Neues Passwort", "passwords_match": "Passwörter stimmen überein!", "passwords_no_match": "Passwörter stimmen nicht überein", - "subtitle": "Wählen Sie ein neues Passwort für Ihren Account.", + "subtitle": "Such dir ein neues Passwort für dein Konto aus.", "success": { - "description": "Ihr Passwort wurde erfolgreich aktualisiert.", - "title": "Passwort aktualisiert" + "description": "Dein Passwort wurde geändert.", + "title": "Passwort geändert" }, - "title": "Ändern Sie Ihr Passwort" + "title": "Ändere dein Passwort" }, "password_reset": { "consumed": { - "subtitle": "Um ein neues Passwort zu erstellen, beginnen Sie von vorne und wählen Sie „Passwort vergessen“.", - "title": "Der Link zum Zurücksetzen Ihres Passworts wurde bereits verwendet" + "subtitle": "Um ein neues Passwort zu erstellen, fang einfach von vorne an und wähle „Passwort vergessen“.", + "title": "Der Link zum Zurücksetzen deines Passworts wurde bereits verwendet" }, "expired": { "resend_email": "E-Mail erneut senden", - "subtitle": "Fordern Sie eine neue E-Mail an, die an folgende Adresse gesendet wird: {{email}}", - "title": "Der Link zum Zurücksetzen Ihres Passworts ist abgelaufen" + "subtitle": "Eine neue E-Mail anfordern, die an folgende Adresse gesendet wird: {{email}}", + "title": "Der Link zum Zurücksetzen deines Passworts ist abgelaufen" }, - "subtitle": "Wählen Sie ein neues Passwort für Ihren Account.", - "title": "Ihr Passwort zurücksetzen" + "subtitle": "Such dir ein neues Passwort für dein Konto aus.", + "title": "Setze dein Passwort zurück" }, "password_strength": { "placeholder": "Passwortstärke", @@ -218,20 +218,20 @@ "4": "Sehr starkes Passwort" }, "suggestion": { - "all_uppercase": "Schreiben Sie einige, aber nicht alle Buchstaben groß.", - "another_word": "Fügen Sie weitere Wörter hinzu, die weniger gebräuchlich sind.", - "associated_years": "Vermeiden Sie Jahre, die mit Ihnen in Verbindung gebracht werden.", - "capitalization": "Schreiben Sie mehr als den ersten Buchstaben groß.", - "dates": "Vermeiden Sie Daten und Jahre, die mit Ihnen in Verbindung gebracht werden.", - "l33t": "Vermeiden Sie vorhersehbare Buchstabenersetzungen wie '@' für 'a'.", - "longer_keyboard_pattern": "Verwenden Sie längere Eingaben und ändern Sie die Tipprichtung mehrmals.", - "no_need": "Sie können sichere Passwörter erstellen, ohne Symbole, Zahlen oder Großbuchstaben zu verwenden.", - "pwned": "Wenn Sie dieses Passwort woanders verwenden, sollten Sie es ändern.", - "recent_years": "Vermeiden Sie die letzten Jahre.", - "repeated": "Vermeiden Sie Wort- und Zeichenwiederholungen.", - "reverse_words": "Vermeiden Sie umgekehrte Schreibweisen gängiger Wörter.", - "sequences": "Vermeiden Sie gängige Zeichenfolgen.", - "use_words": "Verwenden Sie mehrere Wörter, vermeiden Sie jedoch gebräuchliche Ausdrücke." + "all_uppercase": "Schreib ein paar Buchstaben groß, aber nicht alle.", + "another_word": "Füge mehr Wörter hinzu, die weniger gebräuchlich sind.", + "associated_years": "Vermeide Jahre, die mit dir in Verbindung stehen.", + "capitalization": "Schreib mehr als nur den ersten Buchstaben groß.", + "dates": "Vermeide Daten und Jahreszahlen, die mit dir in Verbindung stehen.", + "l33t": "Vermeide vorhersehbare Buchstabenersetzungen wie „@“ für „a“.", + "longer_keyboard_pattern": "Benutz längere Tastaturmuster und wechsel mehrmals die Schreibrichtung.", + "no_need": "Du kannst starke Passwörter erstellen, ohne Symbole, Zahlen oder Großbuchstaben zu benutzen.", + "pwned": "Wenn du dieses Passwort auch woanders benutzt, solltest du es ändern.", + "recent_years": "Vermeide die letzten Jahre.", + "repeated": "Vermeide es, Wörter und Zeichen zu wiederholen.", + "reverse_words": "Vermeide es, gängige Wörter rückwärts zu schreiben.", + "sequences": "Vermeide gängige Zeichenfolgen.", + "use_words": "Benutze mehrere Wörter, aber vermeide gängige Redewendungen." }, "too_weak": "Dieses Passwort ist zu schwach", "warning": { @@ -241,12 +241,12 @@ "extended_repeat": "Wiederholte Zeichenmuster wie „abcabcabc“ sind leicht zu erraten.", "key_pattern": "Kurze Eingaben sind leicht zu erraten.", "names_by_themselves": "Einzelne Vor- oder Nachnamen sind leicht zu erraten.", - "pwned": "Ihr Passwort wurde durch eine Datenpanne im Internet preisgegeben.", + "pwned": "Dein Passwort wurde durch eine Datenpanne im Internet preisgegeben.", "recent_years": "Die letzten Jahre sind leicht zu erraten.", "sequences": "Gängige Zeichenfolgen wie „abc“ sind leicht zu erraten.", "similar_to_common": "Dies ähnelt einem häufig verwendeten Passwort.", "simple_repeat": "Sich wiederholende Zeichen wie \"aaa\" sind leicht zu erraten.", - "straight_row": "Gerade Tastenreihen auf Ihrer Tastatur sind leicht zu erraten.", + "straight_row": "Gerade Reihen von Tasten auf deiner Tastatur sind leicht zu erraten.", "top_hundred": "Dies ist ein häufig verwendetes Passwort.", "top_ten": "Dies ist ein häufig verwendetes Passwort.", "user_inputs": "Es sollten keine persönlichen oder seitenbezogenen Daten vorhanden sein.", @@ -256,32 +256,32 @@ "reset_cross_signing": { "button": "Identität zurücksetzen", "cancelled": { - "description_1": "Sie können dieses Fenster schließen und zur App zurückkehren, um fortzufahren.", - "description_2": "Wenn Sie überall abgemeldet sind und Ihren Wiederherstellungscode vergessen haben, müssen Sie Ihre Identität trotzdem zurücksetzen.", + "description_1": "Du kannst dieses Fenster schließen und zur App zurückgehen, um weiterzumachen.", + "description_2": "Wenn du dich überall abgemeldet hast und deinen Wiederherstellungs-Schlüssel nicht mehr weißt, musst du deine Identität zurücksetzen.", "heading": "Identitätszurücksetzung abgebrochen." }, - "description": "Wenn Sie nirgendwo anders angemeldet sind und alle Wiederherstellungsschlüssel verloren haben, müssen Sie Ihre Krypto-Identität zurücksetzen, bevor Sie die App weiterverwenden können", + "description": "Wenn du auf keinem anderen Gerät angemeldet bist und deinen Wiederherstellungs-Schlüssel verloren hast, musst du deine Identität zurücksetzen, um die App weiter nutzen zu können.", "effect_list": { - "negative_1": "Sie werden Ihren bestehenden Nachrichtenverlauf verlieren", - "negative_2": "Sie müssen alle Ihre vorhandenen Geräte und Kontakte erneut überprüfen.", - "neutral_1": "Sie verlieren jeglichen Nachrichtenverlauf, der nur auf dem Server gespeichert ist", - "neutral_2": "Sie müssen alle Ihre vorhandenen Geräte und Kontakte erneut überprüfen.", - "positive_1": "Ihre Kontodaten, Kontakte, Einstellungen und Chat-Liste werden gespeichert" + "negative_1": "Du verlierst deine bestehenden Chats.", + "negative_2": "Du musst alle deine Geräte und Kontakte nochmal verifizieren.", + "neutral_1": "Du verlierst alle Nachrichten, die nur auf dem Server gespeichert sind.", + "neutral_2": "Du musst alle deine Geräte und Kontakte nochmal verifizieren.", + "positive_1": "Deine Kontodaten, Kontakte, Einstellungen und Chat-Liste bleiben erhalten." }, "failure": { - "description": "Dies könnte ein vorübergehendes Problem sein. Bitte versuchen Sie es später erneut. Wenn das Problem weiterhin besteht, wenden Sie sich bitte an Ihren Serveradministrator.", + "description": "Das könnte ein vorübergehendes Problem sein, also versuch's später nochmal. Wenn das Problem weiterhin besteht, wende dich bitte an deinen Server-Admin.", "heading": "Zurücksetzen der Krypto-Identität konnte nicht zugelassen werden", "title": "Krypto-Identität konnte nicht zugelassen werden" }, "finish_reset": "Reset beenden", - "heading": "Setzen Sie Ihre Identität zurück für den Fall, dass Sie sie nicht anders bestätigen können", + "heading": "Erstelle eine neue Identität, solltest du sie nicht auf andere Weise bestätigen können.", "start_reset": "Reset starten", "success": { - "description": "Das Zurücksetzen der Identität wurde für die nächsten {{minutes}} Minuten genehmigt. Sie können dieses Fenster schließen und zur App zurückkehren, um fortzufahren.", - "heading": "Die Identität wurde erfolgreich zurückgesetzt. Kehren Sie zur App zurück, um den Vorgang abzuschließen.", + "description": "Das Zurücksetzen der Identität wurde für die nächsten {{minutes}} Minuten genehmigt. Du kannst dieses Fenster schließen und zur App zurückkehren, um fortzufahren.", + "heading": "Identität erfolgreich zurückgesetzt. Geh zurück zur App, um den Vorgang abzuschließen.", "title": "Das Zurücksetzen der Krypto-Identität ist vorübergehend erlaubt" }, - "warning": "Setzen Sie Ihre Identität nur zurück, wenn Sie keinen Zugriff auf ein anderes angemeldetes Gerät haben und Ihren Wiederherstellungsschlüssel verloren haben." + "warning": "Setze deine Identität nur zurück, wenn du keinen Zugriff auf ein anderes angemeldetes Gerät hast und deinen Wiederherstellungsschlüssel verloren hast." }, "selectable_session": { "label": "Sitzung auswählen" @@ -301,7 +301,7 @@ "name_for_platform": "{{name}} für {{platform}}", "scopes_label": "Berechtigungsumfang", "set_device_name": { - "help": "Geben Sie einen Namen ein, der Ihnen hilft, dieses Gerät zu identifizieren.", + "help": "Gib einen Namen ein, mit dem du dieses Gerät leicht wiederfindest.", "label": "Gerätename", "title": "Gerätename bearbeiten" }, @@ -324,17 +324,17 @@ "unknown_route": "Unbekannte Route {{route}}", "unverified_email_alert": { "button": "Überprüfen und verifizieren", - "text:one": "Sie haben {{count}} nicht verifizierte E-Mail-Adresse.", - "text:other": "Sie haben {{count}} nicht verifizierte E-Mail-Adressen.", + "text:one": "Du hast {{count}} unverifizierte E-Mail-Adresse.", + "text:other": "Du hast {{count}} unverifizierte E-Mail-Adressen.", "title": "Nicht verifizierte E-Mail-Adresse" }, "user_email": { - "cant_delete_primary": "Wählen Sie eine andere primäre E-Mail-Adresse, um diese zu löschen.", + "cant_delete_primary": "Wähle eine andere primäre E-Mail-Adresse aus, um diese zu löschen.", "delete_button_confirmation_modal": { "action": "E-Mail löschen", "body": "Diese E-Mail löschen?", - "incorrect_password": "Falsches Passwort, bitte versuchen Sie es erneut", - "password_confirmation": "Bestätigen Sie Ihr Kontopasswort, um diese E-Mail-Adresse zu löschen" + "incorrect_password": "Falsches Passwort, versuch's nochmal", + "password_confirmation": "Bestätige dein Passwort, um diese E-Mail-Adresse zu löschen." }, "delete_button_title": "E-Mail-Adresse entfernen", "email": "E-Mail", @@ -357,29 +357,29 @@ "user_sessions_overview": { "active_sessions:one": "{{count}} aktive Sitzung", "active_sessions:other": "{{count}} aktive Sitzungen", - "heading": "Wo Sie angemeldet sind", + "heading": "Wo du angemeldet bist", "no_active_sessions": { - "default": "Sie sind bei keiner Anwendung angemeldet.", - "inactive_90_days": "Alle Ihre Sitzungen waren in den letzten 90 Tagen aktiv." + "default": "Du bist bei keiner Anwendung angemeldet.", + "inactive_90_days": "Alle deine Sitzungen waren in den letzten 90 Tagen aktiv." } }, "verify_email": { "code_expired_alert": { - "description": "Der Code ist abgelaufen. Bitte fordern Sie einen neuen Code an.", + "description": "Der Code ist abgelaufen. Bitte fordere einen neuen Code an.", "title": "Code abgelaufen" }, "code_field_error": "Code nicht erkannt", "code_field_label": "6-stelliger Code", "code_field_wrong_shape": "Der Code muss 6-stellig sein", "email_sent_alert": { - "description": "Geben Sie unten den neuen Code ein.", + "description": "Gib den neuen Code unten ein.", "title": "Neuer Code gesendet" }, - "enter_code_prompt": "Geben Sie den 6-stelligen Code ein, der an {{email}} gesendet wurde", - "heading": "Bestätigen Sie Ihre E-Mail-Adresse", + "enter_code_prompt": "Gib den 6-stelligen Code ein, der an {{email}} gesendet wurde", + "heading": "Bestätige deine E-Mail", "invalid_code_alert": { - "description": "Überprüfen Sie den Code, der an Ihre E-Mail-Adresse gesendet wurde, und aktualisieren Sie die folgenden Felder, um fortzufahren.", - "title": "Sie haben einen falschen Code eingegeben" + "description": "Überprüfe den Code, der an deine E-Mail-Adresse gesendet wurde, und aktualisiere die folgenden Felder, um fortzufahren.", + "title": "Du hast den falschen Code eingegeben." }, "resend_code": "Code erneut senden", "resend_email": "E-Mail erneut senden", @@ -389,13 +389,13 @@ }, "mas": { "scope": { - "edit_profile": "Ihr Profil und Ihre Kontaktdaten bearbeiten", - "manage_sessions": "Ihre Geräte und Sitzungen verwalten", + "edit_profile": "Bearbeite dein Profil und deine Kontaktdaten", + "manage_sessions": "Verwalte deine Geräte und Sitzungen", "mas_admin": "Beliebige Benutzer verwalten", - "send_messages": "Nachrichten in Ihrem Namen senden", + "send_messages": "Neue Nachrichten in deinem Namen senden", "synapse_admin": "Den Synapse-Homeserver verwalten", - "view_messages": "Ihre vorhandenen Nachrichten und Daten anzeigen", - "view_profile": "Ihre Profilinformationen und Kontaktdaten anzeigen" + "view_messages": "Zeig deine vorhandenen Nachrichten und Daten an", + "view_profile": "Deine Profilinfos und Kontaktdaten anzeigen" } } } \ No newline at end of file diff --git a/frontend/locales/et.json b/frontend/locales/et.json index 5b46be41c..271e268d8 100644 --- a/frontend/locales/et.json +++ b/frontend/locales/et.json @@ -146,7 +146,7 @@ }, "nav": { "devices": "Seadmed", - "plan": "Plan", + "plan": "Teenusepakett", "profile": "Profiil", "sessions": "Sessioonid", "settings": "Seadistused" diff --git a/frontend/locales/fi.json b/frontend/locales/fi.json index 781a09334..8c11dfe85 100644 --- a/frontend/locales/fi.json +++ b/frontend/locales/fi.json @@ -146,7 +146,7 @@ }, "nav": { "devices": "Laitteet", - "plan": "Plan", + "plan": "Tilaus", "profile": "Profiili", "sessions": "Istunnot", "settings": "Asetukset" diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 225ed45e0..eb4f0b3f9 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -146,7 +146,7 @@ }, "nav": { "devices": "Appareil", - "plan": "Plan", + "plan": "Forfait", "profile": "Profil", "sessions": "Sessions", "settings": "Paramètres" diff --git a/frontend/locales/hu.json b/frontend/locales/hu.json index a6e452455..239558da3 100644 --- a/frontend/locales/hu.json +++ b/frontend/locales/hu.json @@ -146,7 +146,7 @@ }, "nav": { "devices": "Eszközök", - "plan": "Plan", + "plan": "Terv", "profile": "Profil", "sessions": "Munkamenetek", "settings": "Beállítások" diff --git a/frontend/locales/nl.json b/frontend/locales/nl.json index 3f106fc56..21abb9aa8 100644 --- a/frontend/locales/nl.json +++ b/frontend/locales/nl.json @@ -30,7 +30,7 @@ "error": "Fout", "loading": "Laden...", "next": "Volgende", - "password": "Password", + "password": "Wachtwoord", "previous": "Vorige", "saved": "Opgeslagen", "saving": "Opslaan..." diff --git a/frontend/locales/pt.json b/frontend/locales/pt.json index 51b310a16..5503dd642 100644 --- a/frontend/locales/pt.json +++ b/frontend/locales/pt.json @@ -46,7 +46,7 @@ "dialog_description": "Confirme que pretende eliminar a conta:\n\n\nNão será possível reativar a conta\nDeixará de poder iniciar sessão\nNinguém poderá reutilizar o seu nome de utilizador (MXID), incluindo o próprio\nDeixará todas as salas e mensagens diretas em que participa\nSerá removido do servidor de identidade e ninguém o poderá encontrar com o seu e-mail ou número de telefone\n\nAs suas mensagens antigas continuarão visíveis para as pessoas que as receberam. Gostaria de ocultar as suas mensagens enviadas a pessoas que entrem em salas no futuro?", "dialog_title": "Eliminar esta conta?", "erase_checkbox_label": "Sim, ocultar todas as minhas mensagens de novos marceneiros", - "incorrect_password": "Palavra-passe incorrecta, tente novamente", + "incorrect_password": "Palavra-passe incorreta, tente novamente", "mxid_label": "Confirme o seu ID do Matrix ({{ mxid }})", "mxid_mismatch": "Este valor não corresponde ao seu ID da matrix", "password_label": "Introduza a sua palavra-passe para continuar" @@ -122,7 +122,7 @@ "web": "Web" }, "email_in_use": { - "heading": "O endereço de correio eletrónico {{email}} já está a ser utilizado." + "heading": "O endereço de e-mail {{email}} já está a ser utilizado." }, "end_session_button": { "confirmation_modal_title": "Tem a certeza de que quer terminar esta sessão?", @@ -146,7 +146,7 @@ }, "nav": { "devices": "Dispositivos", - "plan": "Plan", + "plan": "Plano", "profile": "Perfil", "sessions": "Sessões", "settings": "Configurações" @@ -172,13 +172,13 @@ "current_password_label": "Palavra-passe atual", "failure": { "description": { - "account_locked": "A sua conta está bloqueada e não pode ser recuperada neste momento. Se isso não for esperado, entre em contato com o administrador do servidor.", - "expired_recovery_ticket": "O link de recuperação expirou. Por favor, inicie o processo de recuperação de conta novamente desde o início.", + "account_locked": "A sua conta está bloqueada e não pode ser recuperada neste momento. Se isso não for esperado, entre em contacto com o administrador do servidor.", + "expired_recovery_ticket": "O link de recuperação expirou. Inicie o processo de recuperação da conta desde o início.", "invalid_new_password": "A nova senha que você escolheu é inválida; ela pode não atender à política de segurança configurada.", "no_current_password": "Você não tem uma senha atual.", - "no_such_recovery_ticket": "O link de recuperação é inválido. Se você copiou o link do e-mail de recuperação, verifique se o link completo foi copiado.", + "no_such_recovery_ticket": "O link de recuperação é inválido. Se copiou o link a partir do e-mail de recuperação, verifique se o copiou na totalidade.", "password_changes_disabled": "As alterações de palavra-passe estão desactivadas.", - "recovery_ticket_already_used": "O link de recuperação já foi usado. Não pode ser utilizado novamente.", + "recovery_ticket_already_used": "O link de recuperação já foi utilizado e não pode ser utilizado novamente.", "unspecified": "Este pode ser um problema temporário, por isso, tente novamente mais tarde. Se o problema persistir, entre em contato com o administrador do servidor.", "wrong_password": "A palavra-passe que forneceu como palavra-passe atual está incorreta. Por favor, tente novamente." }, @@ -235,10 +235,10 @@ }, "too_weak": "Esta palavra-passe é demasiado fraca", "warning": { - "common": "Esta é uma senha comumente usada.", + "common": "Esta é uma palavra-passe frequentemente utilizada.", "common_names": "Nomes e sobrenomes comuns são fáceis de adivinhar.", "dates": "As datas são fáceis de adivinhar.", - "extended_repeat": "Padrões de caracteres repetidos como \"abcabcabc\" são fáceis de adivinhar.", + "extended_repeat": "Padrões repetidos de carateres, como 'abcabcabc', são fáceis de adivinhar.", "key_pattern": "Padrões de teclado curtos são fáceis de adivinhar.", "names_by_themselves": "Nomes individuais ou sobrenomes são fáceis de adivinhar.", "pwned": "A sua palavra-passe foi exposta por uma violação de dados na Internet.", @@ -257,15 +257,15 @@ "button": "Redefinir identidade", "cancelled": { "description_1": "Pode fechar esta janela e voltar à aplicação para continuar.", - "description_2": "Se você estiver desconectado em todos os lugares e não se lembrar do código de recuperação, ainda precisará redefinir sua identidade.", + "description_2": "Caso tenha terminado sessão em todos os dispositivos e não se recorde do seu código de recuperação, continuará a ser necessário repor a sua identidade.", "heading": "Redefinição de identidade cancelada." }, "description": "Se não tiver sessão iniciada noutros dispositivos e tiver perdido a sua chave de recuperação, terá de repor a sua identidade para continuar a utilizar a aplicação.", "effect_list": { "negative_1": "Perderá o histórico de mensagens existente", - "negative_2": "Você precisará verificar todos os seus dispositivos e contatos existentes novamente", + "negative_2": "Terá de verificar novamente todos os seus dispositivos e contactos existentes.", "neutral_1": "Perderá qualquer histórico de mensagens que esteja armazenado apenas no servidor", - "neutral_2": "Você precisará verificar todos os seus dispositivos e contatos existentes novamente", + "neutral_2": "Terá de verificar novamente todos os seus dispositivos e contactos existentes.", "positive_1": "Os detalhes da sua conta, contactos, preferências e lista de conversação serão mantidos" }, "failure": { @@ -305,7 +305,7 @@ "label": "Nome do dispositivo", "title": "Editar nome do dispositivo" }, - "signed_in_date": "Conectado ", + "signed_in_date": "Sessão iniciada ", "signed_in_label": "Sessão iniciada", "title": "Detalhes do dispositivo", "unknown_browser": "Navegador desconhecido", @@ -333,7 +333,7 @@ "delete_button_confirmation_modal": { "action": "Excluir e-mail", "body": "Excluir este e-mail?", - "incorrect_password": "Palavra-passe incorrecta, tente novamente", + "incorrect_password": "Palavra-passe incorreta, tente novamente", "password_confirmation": "Confirme a palavra-passe da sua conta para eliminar este endereço de correio eletrónico" }, "delete_button_title": "Remover endereço de e-mail", @@ -357,9 +357,9 @@ "user_sessions_overview": { "active_sessions:one": "{{count}} sessão ativa", "active_sessions:other": "{{count}} sessões ativas", - "heading": "Onde você está conectado", + "heading": "Onde tem sessão iniciada", "no_active_sessions": { - "default": "Você não está conectado a nenhum aplicativo.", + "default": "Não tem sessão iniciada em nenhuma aplicação.", "inactive_90_days": "Todas as suas sessões estiveram ativas nos últimos 90 dias." } }, diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index ce48b6c41..743597672 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -17,7 +17,7 @@ "branding": { "privacy_policy": { "alt": "Ссылка на политику конфиденциальности сервиса", - "link": "Политикой конфидециальности" + "link": "Политика конфидециальности" }, "terms_and_conditions": { "alt": "Ссылка на условия предоставления услуг", @@ -147,7 +147,7 @@ }, "nav": { "devices": "Устройства", - "plan": "Plan", + "plan": "Тарифный план", "profile": "Профиль", "sessions": "Сессии", "settings": "Настройки" @@ -302,9 +302,9 @@ "name_for_platform": "{{name}} для {{platform}}", "scopes_label": "Области", "set_device_name": { - "help": "Set a name that will help you identify this device.", - "label": "Device name", - "title": "Edit device name" + "help": "Установите имя, которое поможет вам идентифицировать это устройство.", + "label": "Имя устройства", + "title": "Переименовать устройство" }, "signed_in_date": "Вошёл ", "signed_in_label": "Вошёл в систему", diff --git a/frontend/locales/sv.json b/frontend/locales/sv.json index 2a9ccc5b4..7b038cfb5 100644 --- a/frontend/locales/sv.json +++ b/frontend/locales/sv.json @@ -30,7 +30,7 @@ "error": "Fel", "loading": "Laddar …", "next": "Nästa", - "password": "Password", + "password": "Lösenord", "previous": "Föregående", "saved": "Sparat", "saving": "Sparar..." diff --git a/frontend/locales/uk.json b/frontend/locales/uk.json index cb5434697..e15d27af9 100644 --- a/frontend/locales/uk.json +++ b/frontend/locales/uk.json @@ -46,7 +46,7 @@ "dialog_description": "Підтвердьте, що хочете видалити свій обліковий запис:\n\n\nВи не зможете повторно активувати свій обліковий запис\nВи більше не зможете ввійти \nНіхто не зможе повторно використовувати ваше ім'я користувача (MXID), включно з вами\nВи вийдете з усіх кімнат та особистих розмов, в яких ви перебуваєте\nВас буде вилучено з сервера ідентифікації, і ніхто не зможе знайти вас за вашою електронною поштою або номером телефону\n\nВаші старі повідомлення все одно будуть видимі людям, які їх отримали. Чи хотіли б ви сховати свої надіслані повідомлення від людей, які приєднаються до кімнат у майбутньому?", "dialog_title": "Видалити цей обліковий запис?", "erase_checkbox_label": "Так, сховати всі мої повідомлення від нових учасників", - "incorrect_password": "Пароль неправильний, повторіть спробу", + "incorrect_password": "Неправильний пароль. Повторіть спробу.", "mxid_label": "Підтвердьте свій Matrix ID ({{ mxid }})", "mxid_mismatch": "Це значення не збігається з вашим Matrix ID", "password_label": "Введіть пароль, щоб продовжити" @@ -147,7 +147,7 @@ }, "nav": { "devices": "Пристрої", - "plan": "Plan", + "plan": "План", "profile": "Профіль", "sessions": "Сеанси", "settings": "Налаштування" diff --git a/frontend/locales/zh-Hans.json b/frontend/locales/zh-Hans.json index e54bd1f99..ec5b8534f 100644 --- a/frontend/locales/zh-Hans.json +++ b/frontend/locales/zh-Hans.json @@ -4,11 +4,11 @@ "cancel": "取消", "clear": "清除", "close": "关闭", - "collapse": "Collapse", - "confirm": "Confirm", + "collapse": "折叠", + "confirm": "确认", "continue": "继续", "edit": "编辑", - "expand": "Expand", + "expand": "展开", "save": "保存", "save_and_continue": "保存并继续", "sign_out": "注销", @@ -26,33 +26,33 @@ }, "common": { "add": "添加", - "e2ee": "End-to-end encryption", + "e2ee": "端到端加密", "error": "错误", "loading": "加载中...", - "next": "下一步", - "password": "Password", - "previous": "上一步", + "next": "下一页", + "password": "密码", + "previous": "上一页", "saved": "已保存", "saving": "正在保存..." }, "frontend": { "account": { - "account_password": "Account password", - "contact_info": "Contact info", + "account_password": "账户密码", + "contact_info": "联系方式", "delete_account": { - "alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.", - "alert_title": "You’re about to lose all of your data", - "button": "Delete account", - "dialog_description": "Confirm that you would like to delete your account:\n\n\nYou will not be able to reactivate your account\nYou will no longer be able to sign in\nNo one will be able to reuse your username (MXID), including you\nYou will leave all rooms and direct messages you are in\nYou will be removed from the identity server, and no one will be able to find you with your email or phone number\n\nYour old messages will still be visible to people who received them. Would you like to hide your send messages from people who join rooms in the future?", - "dialog_title": "Delete this account?", - "erase_checkbox_label": "Yes, hide all my messages from new joiners", - "incorrect_password": "Incorrect password, please try again", - "mxid_label": "Confirm your Matrix ID ({{ mxid }})", - "mxid_mismatch": "This value does not match your Matrix ID", - "password_label": "Enter your password to continue" + "alert_description": "此账户将被永久删除,你将无法再访问任何消息。", + "alert_title": "你将丢失所有数据", + "button": "删除账户", + "dialog_description": "确认你想删除账户:\n\n\n你将无法重新激活账户\n你将无法再登录\n包括你在内,没有人能重复使用此用户名(MXID)\n你将离开所有房间与私聊\n你将被从身份服务器中移除,遂没有人能通过你的邮件地址或电话号码找到你\n\n收到过你曾经的消息的人员仍然能看到你的历史消息。是否向未来加入房间的人员隐藏你发送的消息?", + "dialog_title": "删除此账户?", + "erase_checkbox_label": "是,对新加入者隐藏我的所有消息", + "incorrect_password": "密码不正确,请重试", + "mxid_label": "确认你的 Matrix ID({{ mxid }})", + "mxid_mismatch": "此值与你的 Matrix ID 不匹配", + "password_label": "输入密码以继续" }, "edit_profile": { - "display_name_help": "无论您在哪里登录,其他人都会看到此内容。", + "display_name_help": "这是其他人在您登录的地方将看到的信息。", "display_name_label": "显示名称", "title": "编辑个人资料", "username_label": "用户名" @@ -63,8 +63,8 @@ "label": "密码" }, "sign_out": { - "button": "Sign out of account", - "dialog": "Sign out of this account?" + "button": "注销登录", + "dialog": "注销此账户?" }, "title": "你的账户" }, @@ -81,14 +81,14 @@ "email_exists_error": "输入的电子邮件地址已添加到此账户", "email_field_help": "添加可用于访问此账户的备用电子邮件地址。", "email_field_label": "添加电子邮件地址", - "email_in_use_error": "The entered email is already in use", + "email_in_use_error": "输入的邮件地址已被使用", "email_invalid_alert": { "text": "输入的电子邮件地址无效", "title": "无效的电子邮件地址" }, "email_invalid_error": "输入的电子邮件地址无效", - "incorrect_password_error": "Incorrect password, please try again", - "password_confirmation": "Confirm your account password to add this email address" + "incorrect_password_error": "密码不正确,请重试", + "password_confirmation": "确认账户密码以添加此邮件地址" }, "app_sessions_list": { "error": "加载应用程序会话失败", @@ -102,8 +102,8 @@ "body:other": "{{count}}活跃会话", "heading": "浏览器", "no_active_sessions": { - "default": "您尚未登录任何网络浏览器。", - "inactive_90_days": "您的所有会话在过去 90 天内均处于活跃状态。" + "default": "你尚未登录任何 Web 浏览器。", + "inactive_90_days": "你的所有会话在过去 90 天内均处于活跃状态。" }, "view_all_button": "查看全部" }, @@ -121,11 +121,11 @@ "web": "网页" }, "email_in_use": { - "heading": "The email address {{email}} is already in use." + "heading": "此邮件地址 {{email}} 已被使用。" }, "end_session_button": { "confirmation_modal_title": "你确定要结束这个会话吗?", - "text": "登出" + "text": "移除设备" }, "error": { "hideDetails": "隐藏详细信息", @@ -136,7 +136,7 @@ "error_boundary_title": "出了点问题", "errors": { "field_required": "此字段为必填项", - "rate_limit_exceeded": "您在短时间内发出了过多请求。请等待几分钟后重试。" + "rate_limit_exceeded": "你在短时间内发出了过多请求。请于几分钟后重试。" }, "last_active": { "active_date": "活跃 {{relativeDate}}", @@ -171,15 +171,15 @@ "current_password_label": "当前密码", "failure": { "description": { - "account_locked": "您的账户已被锁定,暂时无法恢复。如果这是意外情况,请联系您的服务器管理员。", + "account_locked": "你的账户已被锁定,暂时无法恢复。如果这并非预期,请联系服务器管理员。", "expired_recovery_ticket": "恢复链接已过期。请从头重新开始账户恢复流程。", - "invalid_new_password": "您选择的新密码无效;它可能不符合配置的安全策略。", - "no_current_password": "您当前没有密码。", - "no_such_recovery_ticket": "恢复链接无效。如果您是从恢复电子邮件中复制的链接,请检查是否复制的完整链接。", + "invalid_new_password": "你指定的新密码无效;它可能不符合安全策略配置。", + "no_current_password": "你当前没有密码。", + "no_such_recovery_ticket": "恢复链接无效。如果链接是从恢复电子邮件中复制的,请检查复制的链接是否完整。", "password_changes_disabled": "密码更改已禁用。", "recovery_ticket_already_used": "恢复链接已被使用。无法再次使用。", "unspecified": "这可能是暂时的问题,请稍后再试。如果问题仍然存在,请联系服务器管理员。", - "wrong_password": "您提供的当前密码不正确。请重试。" + "wrong_password": "您提供的当前密码不正确。请再试一次。" }, "title": "更新密码失败" }, @@ -187,24 +187,24 @@ "new_password_label": "新密码", "passwords_match": "密码匹配!", "passwords_no_match": "密码不匹配", - "subtitle": "为您的账户选择一个新密码。", + "subtitle": "为账户指定一个新密码。", "success": { - "description": "您的密码已成功更新。", + "description": "密码已成功更新。", "title": "密码已更新" }, "title": "更改密码" }, "password_reset": { "consumed": { - "subtitle": "To create a new password, start over and select ”Forgot password“.", + "subtitle": "要创建新密码,请重新开始并选择“忘记密码”。", "title": "重置密码的链接已被使用" }, "expired": { "resend_email": "重新发送电子邮件", - "subtitle": "Request a new email that will be sent to: {{email}}", + "subtitle": "请求发送新邮件到:{{email}}", "title": "重置密码的链接已过期" }, - "subtitle": "为您的账户选择一个新密码。", + "subtitle": "为账户指定一个新密码。", "title": "重置密码" }, "password_strength": { @@ -219,13 +219,13 @@ "suggestion": { "all_uppercase": "将部分字母大写,但不是全部字母大写。", "another_word": "添加更多不常用的单词。", - "associated_years": "避开与您有关的年份。", + "associated_years": "避开与你有关的年份。", "capitalization": "不止首字母需要大写。", - "dates": "避免使用与您有关的日期和年份。", + "dates": "避免使用与你有关的日期与年份。", "l33t": "避免可预见的字母替换,例如用\"@\"替换 \"a\"。", "longer_keyboard_pattern": "使用较长的键盘模式,并多次改变输入方向。", "no_need": "不使用符号、数字或大写字母也能创建强密码。", - "pwned": "如果您在其他地方使用该密码,则应进行更改。", + "pwned": "如果你在其它地方使用该密码,则应进行更改。", "recent_years": "避免近几年。", "repeated": "避免重复的词语和字符。", "reverse_words": "避免使用常用单词的反向拼写。", @@ -240,7 +240,7 @@ "extended_repeat": "像“abcabcabc”这样的重复字符模式很容易被猜到。", "key_pattern": "短键盘模式很容易被猜到。", "names_by_themselves": "单个名字或姓氏很容易被猜到。", - "pwned": "您的密码因互联网上的数据泄露而被泄露。", + "pwned": "你的密码因 Internet 上的数据泄露而被泄露。", "recent_years": "最近几年很容易被猜到。", "sequences": "像“abc”这样的常见字符序列很容易被猜到。", "similar_to_common": "这与常用密码类似。", @@ -253,34 +253,34 @@ } }, "reset_cross_signing": { - "button": "允许重置加密身份", + "button": "重置身份", "cancelled": { - "description_1": "You can close this window and go back to the app to continue.", - "description_2": "If you're signed out everywhere and don't remember your recovery code, you'll still need to reset your identity.", - "heading": "Identity reset cancelled." + "description_1": "你可以关闭此窗口并返回到 App 以继续。", + "description_2": "若你在任何地方都已注销并且忘记恢复代码,你仍然需要重置身份。", + "heading": "身份重置流程已被取消。" }, - "description": "如果您没有在其他地方登录,并且忘记或丢失了所有恢复选项,则需要重置您的加密身份。这意味着您将丢失现有的信息历史记录,其他用户会看到您已重置身份,您需要再次验证现有设备。", + "description": "如果你没有在其它地方登录,并且忘记或丢失了恢复密钥,则需要重置加密身份才能继续使用 app。", "effect_list": { - "negative_1": "You will lose your existing message history", - "negative_2": "You will need to verify all your existing devices and contacts again", - "neutral_1": "You will lose any message history that's stored only on the server", - "neutral_2": "You will need to verify all your existing devices and contacts again", - "positive_1": "Your account details, contacts, preferences, and chat list will be kept" + "negative_1": "你将丢失现有消息历史", + "negative_2": "你将需要再次验证所有现有设备与联系人", + "neutral_1": "你将丢失仅存储在服务器上的消息历史", + "neutral_2": "你将需要再次验证所有现有设备与联系人", + "positive_1": "你的账户的详细信息、联系人、偏好与聊天列表都将被保留" }, "failure": { "description": "这可能是暂时的问题,请稍后再试。如果问题仍然存在,请联系服务器管理员。", - "heading": "Failed to allow crypto identity reset", + "heading": "加密身份重置授权失败。", "title": "无法允许加密身份" }, - "finish_reset": "Finish reset", - "heading": "重置加密身份", - "start_reset": "Start reset", + "finish_reset": "完成重置", + "heading": "如果你无法通过其它方式确认请重置身份", + "start_reset": "开始重置", "success": { - "description": "客户端现在可以临时重置您的账户加密身份。请按照客户端中的说明完成该过程。", - "heading": "Identity reset successfully. Go back to the app to finish the process.", + "description": "身份重置已获批准,有效时间为 {{minutes}} 分钟。您可以关闭此窗口并返回应用继续操作。", + "heading": "已成功重置身份。返回到 App 以完成此流程。", "title": "临时允许重置加密身份" }, - "warning": "Only reset your identity if you don't have access to another signed-in device and you've lost your recovery key." + "warning": "仅当你无法访问其它已登录的设备并且丢失了恢复密钥时才重置身份。" }, "selectable_session": { "label": "选择会话" @@ -292,7 +292,7 @@ "device_id_label": "设备 ID", "finished_date": "已完成 ", "finished_label": "已完成", - "generic_browser_session": "Browser session", + "generic_browser_session": "浏览器会话", "id_label": "ID", "ip_label": "IP 地址", "last_active_label": "最后活动", @@ -300,9 +300,9 @@ "name_for_platform": "{{name}}对于 {{platform}}", "scopes_label": "范围", "set_device_name": { - "help": "Set a name that will help you identify this device.", - "label": "Device name", - "title": "Edit device name" + "help": "设置一个名称有助于识别此设备。", + "label": "设备名称", + "title": "编辑设备名称" }, "signed_in_date": "已登录", "signed_in_label": "已登录", @@ -323,16 +323,16 @@ "unknown_route": "未知路线 {{route}}", "unverified_email_alert": { "button": "审查并验证", - "text:other": "您有{{count}} 个未经验证的电子邮件地址。", + "text:other": "你有 {{count}} 个未经验证的邮件地址。", "title": "未经验证的电子邮件地址" }, "user_email": { "cant_delete_primary": "选择其他主电子邮件地址,删除该电子邮件地址。", "delete_button_confirmation_modal": { "action": "删除电子邮件地址", - "body": "您确定要删除此电子邮件地址吗?", - "incorrect_password": "Incorrect password, please try again", - "password_confirmation": "Confirm your account password to delete this email address" + "body": "确定要删除此邮件地址?", + "incorrect_password": "密码不正确,请重试", + "password_confirmation": "确认账户密码以删除此邮件地址" }, "delete_button_title": "删除电子邮件地址", "email": "电子邮件地址", @@ -354,16 +354,16 @@ }, "user_sessions_overview": { "active_sessions:other": "{{count}}活跃会话", - "heading": "您登录的位置", + "heading": "你已登录的位置", "no_active_sessions": { - "default": "您尚未登录任何应用程序。", - "inactive_90_days": "您的所有会话在过去 90 天内均处于活跃状态。" + "default": "你尚未登录任何 app。", + "inactive_90_days": "你的所有会话在过去 90 天内均处于活跃状态。" } }, "verify_email": { "code_expired_alert": { - "description": "The code has expired. Please request a new code.", - "title": "Code expired" + "description": "此代码已过期,请重新请求新代码。", + "title": "代码已过期" }, "code_field_error": "无法识别代码", "code_field_label": "6位数代码", @@ -375,8 +375,8 @@ "enter_code_prompt": "输入发送至以下地址的6位数代码:{{email}}", "heading": "验证邮箱", "invalid_code_alert": { - "description": "检查发送到您电子邮件地址中的代码,并更新以下字段以继续。", - "title": "您输入了错误的代码" + "description": "检查发送到你的邮件地址中的代码,并更新以下字段以继续。", + "title": "你输入的代码错误" }, "resend_code": "重新发送代码", "resend_email": "重新发送电子邮件", @@ -389,7 +389,7 @@ "edit_profile": "编辑个人资料和联系方式", "manage_sessions": "管理设备和会话", "mas_admin": "管理 matrix-authentication-service 上的用户", - "send_messages": "代您发送新消息", + "send_messages": "以你的名义发送新消息", "synapse_admin": "管理 Synapse 服务器", "view_messages": "查看现有信息和数据", "view_profile": "查看个人资料信息和联系方式" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8203e1b9a..ef6e9f9c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,131 +8,74 @@ "name": "mas-frontend", "version": "0.0.0", "dependencies": { - "@fontsource/inconsolata": "^5.2.6", - "@fontsource/inter": "^5.2.6", - "@radix-ui/react-collapsible": "^1.1.8", - "@radix-ui/react-dialog": "^1.1.11", - "@tanstack/react-query": "^5.80.6", - "@tanstack/react-router": "^1.121.0", - "@vector-im/compound-design-tokens": "4.0.2", - "@vector-im/compound-web": "^7.12.0", + "@fontsource/inconsolata": "^5.2.8", + "@fontsource/inter": "^5.2.8", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@tanstack/react-query": "^5.90.5", + "@tanstack/react-router": "^1.131.44", + "@vector-im/compound-design-tokens": "6.0.0", + "@vector-im/compound-web": "^8.2.4", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "i18next": "^25.2.1", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-i18next": "^15.5.2", - "swagger-ui-dist": "^5.22.0", + "i18next": "^25.6.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.2.4", + "swagger-ui-dist": "^5.29.5", "valibot": "^1.1.0", "vaul": "^1.1.2" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", + "@biomejs/biome": "^2.3.2", "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", - "@codecov/vite-plugin": "^1.9.1", - "@graphql-codegen/cli": "^5.0.7", - "@graphql-codegen/client-preset": "^4.8.0", + "@graphql-codegen/cli": "^6.0.1", + "@graphql-codegen/client-preset": "^5.1.0", "@graphql-codegen/typescript-msw": "^3.0.1", - "@storybook/addon-docs": "^9.0.8", - "@storybook/react-vite": "^9.0.8", - "@tanstack/react-query-devtools": "^5.80.6", - "@tanstack/react-router-devtools": "^1.121.0", - "@tanstack/router-plugin": "^1.121.0", - "@testing-library/jest-dom": "^6.6.3", + "@storybook/addon-docs": "^9.1.13", + "@storybook/react-vite": "^9.1.13", + "@tanstack/react-query-devtools": "^5.90.2", + "@tanstack/react-router-devtools": "^1.131.44", + "@tanstack/router-plugin": "^1.131.44", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.0.0", - "@types/react": "19.1.7", - "@types/react-dom": "19.1.6", - "@types/swagger-ui-dist": "^3.30.5", - "@vitejs/plugin-react": "^4.5.2", - "@vitest/coverage-v8": "^3.2.3", + "@types/node": "^24.9.1", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/swagger-ui-dist": "^3.30.6", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.21", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.11.0", - "happy-dom": "^17.6.3", + "happy-dom": "^20.0.4", "i18next-parser": "^9.3.0", - "knip": "^5.59.1", - "msw": "^2.8.7", + "knip": "^5.66.4", + "msw": "^2.11.6", "msw-storybook-addon": "^2.0.5", - "postcss": "^8.5.4", - "postcss-import": "^16.1.0", - "postcss-nesting": "^13.0.1", + "postcss": "^8.5.6", + "postcss-import": "^16.1.1", + "postcss-nesting": "^13.0.2", "rimraf": "^6.0.1", - "storybook": "^9.0.1", - "storybook-react-i18next": "^4.0.4", - "tailwindcss": "^3.4.17", - "typescript": "^5.8.3", - "vite": "6.3.5", + "storybook": "^9.1.5", + "tailwindcss": "^3.4.18", + "typescript": "^5.9.3", + "vite": "7.1.12", "vite-plugin-compression": "^0.5.1", - "vite-plugin-graphql-codegen": "^3.6.1", + "vite-plugin-graphql-codegen": "^3.7.0", "vite-plugin-manifest-sri": "^0.2.0", - "vitest": "^3.1.2" + "vitest": "^3.2.4" } }, - "node_modules/@actions/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", - "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/exec": "^1.1.1", - "@actions/http-client": "^2.0.1" - } - }, - "node_modules/@actions/exec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", - "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/io": "^1.0.1" - } - }, - "node_modules/@actions/github": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.1.tgz", - "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/http-client": "^2.2.0", - "@octokit/core": "^5.0.1", - "@octokit/plugin-paginate-rest": "^9.2.2", - "@octokit/plugin-rest-endpoint-methods": "^10.4.0", - "@octokit/request": "^8.4.1", - "@octokit/request-error": "^5.1.1", - "undici": "^5.28.5" - } - }, - "node_modules/@actions/http-client": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", - "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tunnel": "^0.0.6", - "undici": "^5.25.4" - } - }, - "node_modules/@actions/io": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", - "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, "license": "MIT" }, @@ -204,9 +147,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", - "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -214,22 +157,22 @@ } }, "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", + "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -244,27 +187,17 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -272,13 +205,13 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -301,29 +234,19 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", + "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", + "@babel/traverse": "^7.28.3", "semver": "^6.3.1" }, "engines": { @@ -333,14 +256,14 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { @@ -372,15 +295,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -475,27 +398,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -573,13 +496,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", - "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -666,9 +589,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", - "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", + "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", "dev": true, "license": "MIT", "dependencies": { @@ -682,18 +605,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", - "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "globals": "^11.1.0" + "@babel/traverse": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -720,13 +643,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", - "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -854,9 +778,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", - "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "dev": true, "license": "MIT", "dependencies": { @@ -886,9 +810,9 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.27.1.tgz", - "integrity": "sha512-p9+Vl3yuHPmkirRrg021XiP+EETmPMQTLr6Ayjj85RLNEbb3Eya/4VI0vAdzQG9SEAl2Lnt7fy5lZyMzjYoZQQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", "dev": true, "license": "MIT", "dependencies": { @@ -1003,13 +927,13 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", - "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", + "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", @@ -1043,9 +967,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.3.tgz", - "integrity": "sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1067,28 +991,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1110,11 +1034,10 @@ } }, "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.2.tgz", + "integrity": "sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==", "dev": true, - "hasInstallScript": true, "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -1127,20 +1050,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" + "@biomejs/cli-darwin-arm64": "2.3.2", + "@biomejs/cli-darwin-x64": "2.3.2", + "@biomejs/cli-linux-arm64": "2.3.2", + "@biomejs/cli-linux-arm64-musl": "2.3.2", + "@biomejs/cli-linux-x64": "2.3.2", + "@biomejs/cli-linux-x64-musl": "2.3.2", + "@biomejs/cli-win32-arm64": "2.3.2", + "@biomejs/cli-win32-x64": "2.3.2" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.2.tgz", + "integrity": "sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew==", "cpu": [ "arm64" ], @@ -1155,9 +1078,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.2.tgz", + "integrity": "sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA==", "cpu": [ "x64" ], @@ -1172,9 +1095,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.2.tgz", + "integrity": "sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw==", "cpu": [ "arm64" ], @@ -1189,9 +1112,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.2.tgz", + "integrity": "sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw==", "cpu": [ "arm64" ], @@ -1206,9 +1129,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.2.tgz", + "integrity": "sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA==", "cpu": [ "x64" ], @@ -1223,9 +1146,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.2.tgz", + "integrity": "sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA==", "cpu": [ "x64" ], @@ -1240,9 +1163,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.2.tgz", + "integrity": "sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg==", "cpu": [ "arm64" ], @@ -1257,9 +1180,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.2.tgz", + "integrity": "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ==", "cpu": [ "x64" ], @@ -1291,76 +1214,10 @@ "integrity": "sha512-diidPiK62E4hlAh0dyLfWQDZXi2SSAGiOuw6iqD1x8ztw7L/Sz3He46FhcxEzYa1hKi1blCkjnKDjqw6rQfgcA==", "dev": true }, - "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cookie": "^0.7.2" - } - }, - "node_modules/@bundled-es-modules/statuses": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", - "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, - "license": "ISC", - "dependencies": { - "statuses": "^2.0.1" - } - }, - "node_modules/@bundled-es-modules/tough-cookie": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", - "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@types/tough-cookie": "^4.0.5", - "tough-cookie": "^4.1.4" - } - }, - "node_modules/@codecov/bundler-plugin-core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@codecov/bundler-plugin-core/-/bundler-plugin-core-1.9.1.tgz", - "integrity": "sha512-dt3ic7gMswz4p/qdkYPVJwXlLiLsz55rBBn2I7mr0HTG8pCoLRqnANJIwo5WrqGBZgPyVSMPBqBra6VxLWfDyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/core": "^1.10.1", - "@actions/github": "^6.0.0", - "chalk": "4.1.2", - "semver": "^7.5.4", - "unplugin": "^1.10.1", - "zod": "^3.22.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@codecov/vite-plugin": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@codecov/vite-plugin/-/vite-plugin-1.9.1.tgz", - "integrity": "sha512-S6Yne7comVulJ1jD3T7rCfYFHPR0zUjAYoLjUDPXNJCUrdzWJdf/ak/UepE7TicqQG+yBa6eb5WusqcPgg+1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@codecov/bundler-plugin-core": "^1.9.1", - "unplugin": "^1.10.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "vite": "4.x || 5.x || 6.x" - } - }, "node_modules/@csstools/selector-resolve-nested": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz", - "integrity": "sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", + "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", "dev": true, "funding": [ { @@ -1404,21 +1261,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.2", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", "dev": true, "license": "MIT", "optional": true, @@ -1427,9 +1284,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, @@ -1438,9 +1295,9 @@ } }, "node_modules/@envelop/core": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.2.3.tgz", - "integrity": "sha512-KfoGlYD/XXQSc3BkM1/k15+JQbkQ4ateHazeZoWl9P71FsLTDXSjGy6j7QqfhpIDSbxNISqhPMfZHYSbDFOofQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.3.1.tgz", + "integrity": "sha512-n29V3vRqXvPcG76C8zE482LQykk0P66zv1mjpk7aHeGe9qnh8AzB/RvoX5SVFwApJQPp0ixob8NoYXg4FHKMGA==", "dev": true, "license": "MIT", "dependencies": { @@ -1482,9 +1339,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", - "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "cpu": [ "ppc64" ], @@ -1499,9 +1356,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", - "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "cpu": [ "arm" ], @@ -1516,9 +1373,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", - "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "cpu": [ "arm64" ], @@ -1533,9 +1390,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", - "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "cpu": [ "x64" ], @@ -1550,9 +1407,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", - "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "cpu": [ "arm64" ], @@ -1567,9 +1424,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", - "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "cpu": [ "x64" ], @@ -1584,9 +1441,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", - "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "cpu": [ "arm64" ], @@ -1601,9 +1458,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", - "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "cpu": [ "x64" ], @@ -1618,9 +1475,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", - "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "cpu": [ "arm" ], @@ -1635,9 +1492,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", - "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "cpu": [ "arm64" ], @@ -1652,9 +1509,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", - "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "cpu": [ "ia32" ], @@ -1669,9 +1526,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", - "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "cpu": [ "loong64" ], @@ -1686,9 +1543,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", - "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "cpu": [ "mips64el" ], @@ -1703,9 +1560,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", - "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "cpu": [ "ppc64" ], @@ -1720,9 +1577,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", - "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "cpu": [ "riscv64" ], @@ -1737,9 +1594,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", - "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "cpu": [ "s390x" ], @@ -1754,9 +1611,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", - "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "cpu": [ "x64" ], @@ -1771,9 +1628,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", - "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "cpu": [ "arm64" ], @@ -1788,9 +1645,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", - "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "cpu": [ "x64" ], @@ -1805,9 +1662,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", - "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "cpu": [ "arm64" ], @@ -1822,9 +1679,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", - "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "cpu": [ "x64" ], @@ -1838,10 +1695,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", - "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "cpu": [ "x64" ], @@ -1856,9 +1730,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", - "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "cpu": [ "arm64" ], @@ -1873,9 +1747,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", - "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ "ia32" ], @@ -1890,9 +1764,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", - "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "cpu": [ "x64" ], @@ -1907,39 +1781,39 @@ } }, "node_modules/@fastify/busboy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz", - "integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "dev": true, "license": "MIT" }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.8.tgz", - "integrity": "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw==", + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.2", - "@floating-ui/utils": "^0.2.9", + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -1948,12 +1822,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -1961,39 +1835,42 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, "node_modules/@fontsource/inconsolata": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@fontsource/inconsolata/-/inconsolata-5.2.6.tgz", - "integrity": "sha512-TRGh7bN+BN/oP8qD1IYe8REXM/0Uw3jbuERSncA/ZF6mqKFEOeTt6PR2T3xK7G+65N9pn2p0ablamdboee2nFQ==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inconsolata/-/inconsolata-5.2.8.tgz", + "integrity": "sha512-lIZW+WOZYpUH91g9r6rYYhfTmptF3YPPM54ZOs8IYVeeL4SeiAu4tfj7mdr8llYEq31DLYgi6JtGIJa192gB0Q==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@fontsource/inter": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.6.tgz", - "integrity": "sha512-CZs9S1CrjD0jPwsNy9W6j0BhsmRSQrgwlTNkgQXTsAeDRM42LBRLo3eo9gCzfH4GvV7zpyf78Ozfl773826csw==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@graphql-codegen/add": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", - "integrity": "sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.0.tgz", + "integrity": "sha512-biFdaURX0KTwEJPQ1wkT6BRgNasqgQ5KbCI1a3zwtLtO7XTo7/vKITPylmiU27K5DSOWYnY/1jfSqUAEBuhZrQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -2006,18 +1883,18 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/cli": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.7.tgz", - "integrity": "sha512-h/sxYvSaWtxZxo8GtaA8SvcHTyViaaPd7dweF/hmRDpaQU1o3iU3EZxlcJ+oLTunU0tSMFsnrIXm/mhXxI11Cw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.0.1.tgz", + "integrity": "sha512-6iP91joxb7phdicDrIF8Cv9ah2QpPVXUUu7rbOaQKvqey+QKYmHcxGCi9r5/7p4lUiHZPQvfB7xDHURHQca1SA==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^4.8.2", - "@graphql-codegen/core": "^4.0.2", - "@graphql-codegen/plugin-helpers": "^5.1.1", + "@graphql-codegen/client-preset": "^5.0.0", + "@graphql-codegen/core": "^5.0.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/apollo-engine-loader": "^8.0.0", "@graphql-tools/code-file-loader": "^8.0.0", "@graphql-tools/git-loader": "^8.0.0", @@ -2025,20 +1902,19 @@ "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", - "@graphql-tools/prisma-loader": "^8.0.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", + "@inquirer/prompts": "^7.8.2", "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", - "cosmiconfig": "^8.1.3", - "debounce": "^1.2.0", + "cosmiconfig": "^9.0.0", + "debounce": "^2.0.0", "detect-indent": "^6.0.0", "graphql-config": "^5.1.1", - "inquirer": "^8.0.0", "is-glob": "^4.0.1", - "jiti": "^1.17.1", + "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", - "listr2": "^4.0.5", + "listr2": "^9.0.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", @@ -2067,22 +1943,59 @@ } } }, + "node_modules/@graphql-codegen/cli/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/cli/node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/@graphql-codegen/client-preset": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.2.tgz", - "integrity": "sha512-YoH2obkNLorgT7bs5cbveg6A1fM4ZW5AE/CWLaSzViMTAXk51q0z/5+sTrDW2Ft6Or3mTxFLEByCgXhPgAj2Lw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.1.0.tgz", + "integrity": "sha512-MYMy9dIlAgT3q1U8WUys6Y8yt/T9WLsm1DczRtrCpV5N11v4Rlg3hGWQmEvhJtBbWxgzfYoHZHb0TohtbLkJ+g==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", - "@graphql-codegen/add": "^5.0.3", - "@graphql-codegen/gql-tag-operations": "4.0.17", - "@graphql-codegen/plugin-helpers": "^5.1.1", - "@graphql-codegen/typed-document-node": "^5.1.1", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/typescript-operations": "^4.6.1", - "@graphql-codegen/visitor-plugin-common": "^5.8.0", + "@graphql-codegen/add": "^6.0.0", + "@graphql-codegen/gql-tag-operations": "5.0.2", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/typed-document-node": "^6.0.2", + "@graphql-codegen/typescript": "^5.0.2", + "@graphql-codegen/typescript-operations": "^5.0.2", + "@graphql-codegen/visitor-plugin-common": "^6.1.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -2109,17 +2022,20 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz", - "integrity": "sha512-IZbpkhwVqgizcjNiaVzNAzm/xbWT6YnGgeOLwVjm4KbJn3V2jchVtuzHH09G5/WkkLSk2wgbXNdwjM41JxO6Eg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-5.0.0.tgz", + "integrity": "sha512-vLTEW0m8LbE4xgRwbFwCdYxVkJ1dBlVJbQyLb9Q7bHnVFgHAP982Xo8Uv7FuPBmON+2IbTjkCqhFLHVZbqpvjQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -2132,14 +2048,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.17.tgz", - "integrity": "sha512-2pnvPdIG6W9OuxkrEZ6hvZd142+O3B13lvhrZ48yyEBh2ujtmKokw0eTwDHtlXUqjVS0I3q7+HB2y12G/m69CA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.0.2.tgz", + "integrity": "sha512-iK+LFGv4ihHKeerADFPTL7Iq4iNr+J1jm2+GUMtwTSAL4nGk+BdfyruV7eR53R7Des8NFdI+9hBzKbbob7VwGQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -2159,9 +2075,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", - "integrity": "sha512-28GHODK2HY1NhdyRcPP3sCz0Kqxyfiz7boIZ8qIxFYmpLYnlDgiYok5fhFLVSZihyOpCs4Fa37gVHf/Q4I2FEg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.0.0.tgz", + "integrity": "sha512-Z7P89vViJvQakRyMbq/JF2iPLruRFOwOB6IXsuSvV/BptuuEd7fsGPuEf8bdjjDxUY0pJZnFN8oC7jIQ8p9GKA==", "dev": true, "license": "MIT", "dependencies": { @@ -2187,16 +2103,19 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/schema-ast": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", - "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.0.tgz", + "integrity": "sha512-jn7Q3PKQc0FxXjbpo9trxzlz/GSFQWxL042l0iC8iSbM/Ar+M7uyBwMtXPsev/3Razk+osQyreghIz0d2+6F7Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -2209,14 +2128,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.1.1.tgz", - "integrity": "sha512-Bp/BrMZDKRwzuVeLv+pSljneqONM7gqu57ZaV34Jbncu2hZWMRDMfizTKghoEwwZbRCYYfJO9tA0sYVVIfI1kg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.0.2.tgz", + "integrity": "sha512-nqcD23F87jLPQ1P2jJaepNAa4SY8Xy2soacPyQMwvxWtbRSXlg/LBUjtbEkCaU2SuLoa4L3w8VPuGoQ3EWUzeg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" @@ -2236,15 +2155,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", - "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.2.tgz", + "integrity": "sha512-OJYXpS9SRf4VFzqu3ZH/RmTftGhAVTCmscH63iPlvTlCT8NBmpSHdZ875AEa38LugdL8XgUcGsI3pprP3e5j/w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/schema-ast": "^4.0.2", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/schema-ast": "^5.0.0", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2405,9 +2324,9 @@ } }, "node_modules/@graphql-codegen/typescript-msw/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2518,6 +2437,16 @@ "node": ">=8" } }, + "node_modules/@graphql-codegen/typescript-msw/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@graphql-codegen/typescript-msw/node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -2563,15 +2492,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.1.tgz", - "integrity": "sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.2.tgz", + "integrity": "sha512-i2nSJ5a65H+JgXwWvEuYehVYUImIvrHk3PTs+Fcj+OjZFvDl2qBziIhr6shCjV0KH9IZ6Y+1v4TzkxZr/+XFjA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/typescript": "^5.0.2", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2603,19 +2532,19 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.8.0.tgz", - "integrity": "sha512-lC1E1Kmuzi3WZUlYlqB4fP6+CvbKH9J+haU1iWmgsBx5/sO2ROeXJG4Dmt8gP03bI2BwjiwV5WxCEMlyeuzLnA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.1.0.tgz", + "integrity": "sha512-AvGO1pe+b/kAa7+WBDlNDXOruRZWv/NnhLHgTggiW2XWRv33biuzg4cF1UTdpR2jmESZzJU4kXngLLX8RYJWLA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", - "dependency-graph": "^0.11.0", + "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.6.0" @@ -2627,6 +2556,16 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/@graphql-codegen/visitor-plugin-common/node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@graphql-codegen/visitor-plugin-common/node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -2645,13 +2584,13 @@ } }, "node_modules/@graphql-tools/apollo-engine-loader": { - "version": "8.0.20", - "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.20.tgz", - "integrity": "sha512-m5k9nXSyjq31yNsEqDXLyykEjjn3K3Mo73oOKI+Xjy8cpnsgbT4myeUJIYYQdLrp7fr9Y9p7ZgwT5YcnwmnAbA==", + "version": "8.0.22", + "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.22.tgz", + "integrity": "sha512-ssD2wNxeOTRcUEkuGcp0KfZAGstL9YLTe/y3erTDZtOs2wL1TJESw8NVAp+3oUHPeHKBZQB4Z6RFEbPgMdT2wA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "@whatwg-node/fetch": "^0.10.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" @@ -2664,13 +2603,13 @@ } }, "node_modules/@graphql-tools/batch-execute": { - "version": "9.0.15", - "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.15.tgz", - "integrity": "sha512-qlWUl6yi87FU5WvyJ0uD81R4Y30oQIuW3mJCjOrEvifyT+f/rEqSZFOhYrofYoZAoTcwqOhy6WgH+b9+AtRYjA==", + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.19.tgz", + "integrity": "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" @@ -2683,14 +2622,14 @@ } }, "node_modules/@graphql-tools/code-file-loader": { - "version": "8.1.20", - "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.20.tgz", - "integrity": "sha512-GzIbjjWJIc04KWnEr8VKuPe0FA2vDTlkaeub5p4lLimljnJ6C0QSkOyCUnFmsB9jetQcHm0Wfmn/akMnFUG+wA==", + "version": "8.1.22", + "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.22.tgz", + "integrity": "sha512-FSka29kqFkfFmw36CwoQ+4iyhchxfEzPbXOi37lCEjWLHudGaPkXc3RyB9LdmBxx3g3GHEu43a5n5W8gfcrMdA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.19", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/graphql-tag-pluck": "8.3.21", + "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -2703,16 +2642,16 @@ } }, "node_modules/@graphql-tools/delegate": { - "version": "10.2.17", - "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.17.tgz", - "integrity": "sha512-z+LpZrTQCEXA4fbdJcSsvhaMqT4xi/O8B0mP30ENGyTbSfa20QamOQx9jgCiw2ii/ucwxfGMhygwlpZG36EU4w==", + "version": "10.2.23", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.23.tgz", + "integrity": "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/batch-execute": "^9.0.15", - "@graphql-tools/executor": "^1.4.7", - "@graphql-tools/schema": "^10.0.11", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/batch-execute": "^9.0.19", + "@graphql-tools/executor": "^1.4.9", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", "@repeaterjs/repeater": "^3.0.6", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", @@ -2744,13 +2683,13 @@ } }, "node_modules/@graphql-tools/executor": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.4.7.tgz", - "integrity": "sha512-U0nK9jzJRP9/9Izf1+0Gggd6K6RNRsheFo1gC/VWzfnsr0qjcOSS9qTjY0OTC5iTPt4tQ+W5Zpw/uc7mebI6aA==", + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.4.9.tgz", + "integrity": "sha512-SAUlDT70JAvXeqV87gGzvDzUGofn39nvaVcVhNf12Dt+GfWHtNNO/RCn/Ea4VJaSLGzraUd41ObnN3i80EBU7w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", @@ -2782,19 +2721,36 @@ } }, "node_modules/@graphql-tools/executor-graphql-ws": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.5.tgz", - "integrity": "sha512-gI/D9VUzI1Jt1G28GYpvm5ckupgJ5O8mi5Y657UyuUozX34ErfVdZ81g6oVcKFQZ60LhCzk7jJeykK48gaLhDw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.7.tgz", + "integrity": "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/executor-common": "^0.0.4", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/executor-common": "^0.0.6", + "@graphql-tools/utils": "^10.9.1", "@whatwg-node/disposablestack": "^0.0.6", - "graphql-ws": "^6.0.3", + "graphql-ws": "^6.0.6", "isomorphic-ws": "^5.0.0", "tslib": "^2.8.1", - "ws": "^8.17.1" + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-graphql-ws/node_modules/@graphql-tools/executor-common": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.6.tgz", + "integrity": "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.3.0", + "@graphql-tools/utils": "^10.9.1" }, "engines": { "node": ">=18.0.0" @@ -2828,13 +2784,13 @@ } }, "node_modules/@graphql-tools/executor-legacy-ws": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.17.tgz", - "integrity": "sha512-TvltY6eL4DY1Vt66Z8kt9jVmNcI+WkvVPQZrPbMCM3rv2Jw/sWvSwzUBezRuWX0sIckMifYVh23VPcGBUKX/wg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.19.tgz", + "integrity": "sha512-bEbv/SlEdhWQD0WZLUX1kOenEdVZk1yYtilrAWjRUgfHRZoEkY9s+oiqOxnth3z68wC2MWYx7ykkS5hhDamixg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "@types/ws": "^8.0.0", "isomorphic-ws": "^5.0.0", "tslib": "^2.4.0", @@ -2848,14 +2804,14 @@ } }, "node_modules/@graphql-tools/git-loader": { - "version": "8.0.24", - "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.24.tgz", - "integrity": "sha512-ypLC9N2bKNC0QNbrEBTbWKwbV607f7vK2rSGi9uFeGr8E29tWplo6or9V/+TM0ZfIkUsNp/4QX/zKTgo8SbwQg==", + "version": "8.0.26", + "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.26.tgz", + "integrity": "sha512-0g+9eng8DaT4ZmZvUmPgjLTgesUa6M8xrDjNBltRldZkB055rOeUgJiKmL6u8PjzI5VxkkVsn0wtAHXhDI2UXQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.19", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/graphql-tag-pluck": "8.3.21", + "@graphql-tools/utils": "^10.9.1", "is-glob": "4.0.3", "micromatch": "^4.0.8", "tslib": "^2.4.0", @@ -2869,15 +2825,15 @@ } }, "node_modules/@graphql-tools/github-loader": { - "version": "8.0.20", - "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.20.tgz", - "integrity": "sha512-Icch8bKZ1iP3zXCB9I0ded1hda9NPskSSalw7ZM21kXvLiOR5nZhdqPF65gCFkIKo+O4NR4Bp51MkKj+wl+vpg==", + "version": "8.0.22", + "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.22.tgz", + "integrity": "sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw==", "dev": true, "license": "MIT", "dependencies": { "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/graphql-tag-pluck": "^8.3.19", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/graphql-tag-pluck": "^8.3.21", + "@graphql-tools/utils": "^10.9.1", "@whatwg-node/fetch": "^0.10.0", "@whatwg-node/promise-helpers": "^1.0.0", "sync-fetch": "0.6.0-2", @@ -2891,14 +2847,14 @@ } }, "node_modules/@graphql-tools/graphql-file-loader": { - "version": "8.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.0.19.tgz", - "integrity": "sha512-kyEZL4rRJ5LelfCXL3GLgbMiu5Zd7memZaL8ZxPXGI7DA8On1e5IVBH3zZJwf7LzhjSVnPaHM7O/bRzGvTbXzQ==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.1.1.tgz", + "integrity": "sha512-5JaUE3zMHW21Oh3bGSNKcr/Mi6oZ9/QWlBCNYbGy+09U23EOZmhPn9a44zP3gXcnnj0C+YVEr8dsMaoaB3UVGQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/import": "7.0.18", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/import": "7.1.1", + "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -2911,9 +2867,9 @@ } }, "node_modules/@graphql-tools/graphql-tag-pluck": { - "version": "8.3.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.19.tgz", - "integrity": "sha512-LEw/6IYOUz48HjbWntZXDCzSXsOIM1AyWZrlLoJOrA8QAlhFd8h5Tny7opCypj8FO9VvpPFugWoNDh5InPOEQA==", + "version": "8.3.21", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.21.tgz", + "integrity": "sha512-TJhELNvR1tmghXMi6HVKp/Swxbx1rcSp/zdkuJZT0DCM3vOY11FXY6NW3aoxumcuYDNN3jqXcCPKstYGFPi5GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2922,7 +2878,7 @@ "@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/traverse": "^7.26.10", "@babel/types": "^7.26.10", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "engines": { @@ -2933,13 +2889,14 @@ } }, "node_modules/@graphql-tools/import": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.0.18.tgz", - "integrity": "sha512-1tw1/1QLB0n5bPWfIrhCRnrHIlbMvbwuifDc98g4FPhJ7OXD+iUQe+IpmD5KHVwYWXWhZOuJuq45DfV/WLNq3A==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.1.1.tgz", + "integrity": "sha512-zhlhaUmeTfV76vMoLRn9xCVMVc7sLf10ve5GKEhXFFDcWA6+vEZGk9CCm1VlPf2kyKGlF7bwLVzfepb3ZoOU9Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", + "@theguild/federation-composition": "^0.19.0", "resolve-from": "5.0.0", "tslib": "^2.4.0" }, @@ -2951,13 +2908,13 @@ } }, "node_modules/@graphql-tools/json-file-loader": { - "version": "8.0.18", - "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.18.tgz", - "integrity": "sha512-JjjIxxewgk8HeMR3npR3YbOkB7fxmdgmqB9kZLWdkRKBxrRXVzhryyq+mhmI0Evzt6pNoHIc3vqwmSctG2sddg==", + "version": "8.0.20", + "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.20.tgz", + "integrity": "sha512-5v6W+ZLBBML5SgntuBDLsYoqUvwfNboAwL6BwPHi3z/hH1f8BS9/0+MCW9OGY712g7E4pc3y9KqS67mWF753eA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -2970,14 +2927,14 @@ } }, "node_modules/@graphql-tools/load": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.0.tgz", - "integrity": "sha512-OGfOm09VyXdNGJS/rLqZ6ztCiG2g6AMxhwtET8GZXTbnjptFc17GtKwJ3Jv5w7mjJ8dn0BHydvIuEKEUK4ciYw==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.2.tgz", + "integrity": "sha512-WhDPv25/jRND+0uripofMX0IEwo6mrv+tJg6HifRmDu8USCD7nZhufT0PP7lIcuutqjIQFyogqT70BQsy6wOgw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/schema": "^10.0.23", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", "p-limit": "3.1.0", "tslib": "^2.4.0" }, @@ -2989,13 +2946,13 @@ } }, "node_modules/@graphql-tools/merge": { - "version": "9.0.24", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.0.24.tgz", - "integrity": "sha512-NzWx/Afl/1qHT3Nm1bghGG2l4jub28AdvtG11PoUlmjcIjnFBJMv4vqL0qnxWe8A82peWo4/TkVdjJRLXwgGEw==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", + "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "engines": { @@ -3021,46 +2978,15 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/prisma-loader": { - "version": "8.0.17", - "resolved": "https://registry.npmjs.org/@graphql-tools/prisma-loader/-/prisma-loader-8.0.17.tgz", - "integrity": "sha512-fnuTLeQhqRbA156pAyzJYN0KxCjKYRU5bz1q/SKOwElSnAU4k7/G1kyVsWLh7fneY78LoMNH5n+KlFV8iQlnyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/url-loader": "^8.0.15", - "@graphql-tools/utils": "^10.5.6", - "@types/js-yaml": "^4.0.0", - "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", - "debug": "^4.3.1", - "dotenv": "^16.0.0", - "graphql-request": "^6.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "jose": "^5.0.0", - "js-yaml": "^4.0.0", - "lodash": "^4.17.20", - "scuid": "^1.1.0", - "tslib": "^2.4.0", - "yaml-ast-parser": "^0.0.43" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.19.tgz", - "integrity": "sha512-xnjLpfzw63yIX1bo+BVh4j1attSwqEkUbpJ+HAhdiSUa3FOQFfpWgijRju+3i87CwhjBANqdTZbcsqLT1hEXig==", + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.21.tgz", + "integrity": "sha512-vMdU0+XfeBh9RCwPqRsr3A05hPA3MsahFn/7OAwXzMySA5EVnSH5R4poWNs3h1a0yT0tDPLhxORhK7qJdSWj2A==", "dev": true, "license": "MIT", "dependencies": { "@ardatan/relay-compiler": "^12.0.3", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "engines": { @@ -3071,14 +2997,14 @@ } }, "node_modules/@graphql-tools/schema": { - "version": "10.0.23", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.23.tgz", - "integrity": "sha512-aEGVpd1PCuGEwqTXCStpEkmheTHNdMayiIKH1xDWqYp9i8yKv9FRDgkGrY4RD8TNxnf7iII+6KOBGaJ3ygH95A==", + "version": "10.0.25", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz", + "integrity": "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.0.24", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/merge": "^9.1.1", + "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "engines": { @@ -3089,16 +3015,16 @@ } }, "node_modules/@graphql-tools/url-loader": { - "version": "8.0.31", - "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.31.tgz", - "integrity": "sha512-QGP3py6DAdKERHO5D38Oi+6j+v0O3rkBbnLpyOo87rmIRbwE6sOkL5JeHegHs7EEJ279fBX6lMt8ry0wBMGtyA==", + "version": "8.0.33", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.33.tgz", + "integrity": "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw==", "dev": true, "license": "MIT", "dependencies": { "@graphql-tools/executor-graphql-ws": "^2.0.1", "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/executor-legacy-ws": "^1.1.17", - "@graphql-tools/utils": "^10.8.6", + "@graphql-tools/executor-legacy-ws": "^1.1.19", + "@graphql-tools/utils": "^10.9.1", "@graphql-tools/wrap": "^10.0.16", "@types/ws": "^8.0.0", "@whatwg-node/fetch": "^0.10.0", @@ -3116,9 +3042,9 @@ } }, "node_modules/@graphql-tools/utils": { - "version": "10.8.6", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.8.6.tgz", - "integrity": "sha512-Alc9Vyg0oOsGhRapfL3xvqh1zV8nKoFUdtLhXX7Ki4nClaIJXckrA86j+uxEuG3ic6j4jlM1nvcWXRn/71AVLQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.9.1.tgz", + "integrity": "sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3136,15 +3062,15 @@ } }, "node_modules/@graphql-tools/wrap": { - "version": "10.0.35", - "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.0.35.tgz", - "integrity": "sha512-qBga3wo7+GqY+ClGexiyRz9xgy1RWozZryTuGX8usGWPa4wKi/tJS4rKWQQesgB3Fh//SZUCRA5u2nwZaZQw1Q==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.1.4.tgz", + "integrity": "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/delegate": "^10.2.17", - "@graphql-tools/schema": "^10.0.11", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/delegate": "^10.2.23", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "tslib": "^2.8.1" }, @@ -3178,15 +3104,50 @@ "node": ">=10.13.0" } }, - "node_modules/@inquirer/confirm": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", - "integrity": "sha512-NgQCnHqFTjF7Ys2fsqK2WtnA8X1kHyInyG+nMIuHowVTIgIuS10T4AznI/PvbqSpJqjCUqNBlKGh1v3bwLFL4w==", + "node_modules/@inquirer/ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", + "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.4.tgz", + "integrity": "sha512-2n9Vgf4HSciFq8ttKXk+qy+GsyTXPV1An6QAwe/8bkbbqvG4VW1I/ZY1pNu2rf+h9bdzMLPbRSfcNxkHBy/Ydw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.10", - "@inquirer/type": "^3.0.6" + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.18.tgz", + "integrity": "sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -3201,15 +3162,15 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.10.tgz", - "integrity": "sha512-roDaKeY1PYY0aCqhRmXihrHjoSW2A00pV3Ke5fTpMCkzcGF64R8e0lw3dK+eLEHwS4vB5RnW1wuQmvzoRul8Mw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", + "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", + "@inquirer/ansi": "^1.0.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", @@ -3228,40 +3189,274 @@ } } }, - "node_modules/@inquirer/core/node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "node_modules/@inquirer/editor": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.20.tgz", + "integrity": "sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/external-editor": "^1.0.2", + "@inquirer/type": "^3.0.8" + }, "engines": { - "node": ">= 12" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@inquirer/core/node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "node_modules/@inquirer/expand": { + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.20.tgz", + "integrity": "sha512-Dt9S+6qUg94fEvgn54F2Syf0Z3U8xmnBI9ATq2f5h9xt09fs2IJXSCIXyyVHwvggKWFXEY/7jATRo2K6Dkn6Ow==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@inquirer/figures": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", - "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/@inquirer/input": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.4.tgz", + "integrity": "sha512-cwSGpLBMwpwcZZsc6s1gThm0J+it/KIJ+1qFL2euLmSKUMGumJ5TcbMgxEjMjNHRGadouIYbiIgruKoDZk7klw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.20.tgz", + "integrity": "sha512-bbooay64VD1Z6uMfNehED2A2YOPHSJnQLs9/4WNiV/EK+vXczf/R988itL2XLDGTgmhMF2KkiWZo+iEZmc4jqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.20.tgz", + "integrity": "sha512-nxSaPV2cPvvoOmRygQR+h0B+Av73B01cqYLcr7NXcGXhbmsYfUb8fDdw2Us1bI2YsX+VvY7I7upgFYsyf8+Nug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.6.tgz", + "integrity": "sha512-68JhkiojicX9SBUD8FE/pSKbOKtwoyaVj1kwqLfvjlVXZvOy3iaSWX4dCLsZyYx/5Ur07Fq+yuDNOen+5ce6ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.2.4", + "@inquirer/confirm": "^5.1.18", + "@inquirer/editor": "^4.2.20", + "@inquirer/expand": "^4.0.20", + "@inquirer/input": "^4.2.4", + "@inquirer/number": "^3.0.20", + "@inquirer/password": "^4.0.20", + "@inquirer/rawlist": "^4.1.8", + "@inquirer/search": "^3.1.3", + "@inquirer/select": "^4.3.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.8.tgz", + "integrity": "sha512-CQ2VkIASbgI2PxdzlkeeieLRmniaUU1Aoi5ggEdm6BIyqopE9GuDXdDOj9XiwOqK5qm72oI2i6J+Gnjaa26ejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.3.tgz", + "integrity": "sha512-D5T6ioybJJH0IiSUK/JXcoRrrm8sXwzrVMjibuPs+AgxmogKslaafy1oxFiorNI4s3ElSkeQZbhYQgLqiL8h6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.4.tgz", + "integrity": "sha512-Qp20nySRmfbuJBBsgPU7E/cL62Hf250vMZRzYDcBHty2zdD1kKCnoDFWRr0WO2ZzaXp3R7a4esaVGJUx0E6zvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@inquirer/type": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", - "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", "dev": true, "license": "MIT", "engines": { @@ -3276,6 +3471,29 @@ } } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3295,9 +3513,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -3308,9 +3526,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -3346,9 +3564,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -3390,9 +3608,9 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.0.tgz", - "integrity": "sha512-dPo6SE4dm8UKcgGg4LsV9iw6f5HkIeJwzMA2M2Lb+mhl5vxesbDvb3ENTzNTkGnOxS6PqJig2pfXdtYaW3S9fg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz", + "integrity": "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==", "dev": true, "license": "MIT", "dependencies": { @@ -3402,7 +3620,7 @@ }, "peerDependencies": { "typescript": ">= 4.3.x", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3411,18 +3629,25 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -3435,27 +3660,17 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -3464,9 +3679,9 @@ } }, "node_modules/@mdx-js/react": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", - "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "dev": true, "license": "MIT", "dependencies": { @@ -3482,9 +3697,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.38.7", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", - "integrity": "sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3500,16 +3715,16 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz", - "integrity": "sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -3550,178 +3765,6 @@ "node": ">= 8" } }, - "node_modules/@octokit/auth-token": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", - "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", - "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.1.0", - "@octokit/request": "^8.4.1", - "@octokit/request-error": "^5.1.1", - "@octokit/types": "^13.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/endpoint": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", - "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.1.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", - "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/request": "^8.4.1", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz", - "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^12.6.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "5" - } - }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", - "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", - "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^20.0.0" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", - "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^12.6.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "5" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", - "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", - "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^20.0.0" - } - }, - "node_modules/@octokit/request": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", - "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^9.0.6", - "@octokit/request-error": "^5.1.1", - "@octokit/types": "^13.1.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", - "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.1.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", @@ -3747,10 +3790,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.12.0.tgz", + "integrity": "sha512-/IfGWLNdmS1kVYM2g+Xw4qXNWtCPZ/i5YMprflA8FC3vAjT4N0VucQcDxUPHxatQwre4qnhbFFWqRa1mz6Cgkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.12.0.tgz", + "integrity": "sha512-H3Ehyinfx2VO8F5TwdaD/WY686Ia6J1H3LP0tgpNjlPGH2TrTniPERiwjqtOm/xHEef0KJvb/yfmUKLbHudhCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-9.0.2.tgz", - "integrity": "sha512-MVyRgP2gzJJtAowjG/cHN3VQXwNLWnY+FpOEsyvDepJki1SdAX/8XDijM1yN6ESD1kr9uhBKjGelC6h3qtT+rA==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.12.0.tgz", + "integrity": "sha512-hmm+A/0WdEtIeBrPtUHoSTzJefrZkhGSrmv5pwELKiqNqd+/gctzmTlt6wWrU8BMIryDMT9fWqLSQ3+NYfqAEA==", "cpu": [ "arm64" ], @@ -3762,9 +3833,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-9.0.2.tgz", - "integrity": "sha512-7kV0EOFEZ3sk5Hjy4+bfA6XOQpCwbDiDkkHN4BHHyrBHsXxUR05EcEJPPL1WjItefg+9+8hrBmoK0xRoDs41+A==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.12.0.tgz", + "integrity": "sha512-g1tVu53EMfuRKs67o0PZR0+y/WXl/Tfn3d2ggjK3Hj17pQPcb9x1+Y6W7n4EjIDttwLZbCPCEr06X+aC03I45A==", "cpu": [ "x64" ], @@ -3776,9 +3847,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-9.0.2.tgz", - "integrity": "sha512-6OvkEtRXrt8sJ4aVfxHRikjain9nV1clIsWtJ1J3J8NG1ZhjyJFgT00SCvqxbK+pzeWJq6XzHyTCN78ML+lY2w==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.12.0.tgz", + "integrity": "sha512-TiMatzvcVMSOiAx8sbnAw7UCfQpZDlm91ItywZrSHlQIJqDBipOmjIEYUMc2p823Y+fJ2ADL5UBjUB2kfqpedw==", "cpu": [ "x64" ], @@ -3790,9 +3861,23 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-9.0.2.tgz", - "integrity": "sha512-aYpNL6o5IRAUIdoweW21TyLt54Hy/ZS9tvzNzF6ya1ckOQ8DLaGVPjGpmzxdNja9j/bbV6aIzBH7lNcBtiOTkQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.12.0.tgz", + "integrity": "sha512-zU+9UgxPIvfReqmRr/dqZt3387HPgcH0hA4u0QGE+280EFjBYYL2rxGDxK0L+keO6vc2+ITWVDXm9KIj+alofg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.12.0.tgz", + "integrity": "sha512-dfO1rrOeELYWD/BewMCp81k1I3pOdtAi2VCKg/A1I8z0uI4OR6cThb5dV9fpHkj7zlb0Y5iZFPe+NTbI/u1MgQ==", "cpu": [ "arm" ], @@ -3804,9 +3889,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-9.0.2.tgz", - "integrity": "sha512-RGFW4vCfKMFEIzb9VCY0oWyyY9tR1/o+wDdNePhiUXZU4SVniRPQaZ1SJ0sUFI1k25pXZmzQmIP6cBmazi/Dew==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.12.0.tgz", + "integrity": "sha512-JJNyN1ueryETKTUsG57+u0GDbtHKVcwcUoC6YyJmDdWE0o/3twXtHuS+F/121a2sVK8PKlROqGAev+STx3AuuQ==", "cpu": [ "arm64" ], @@ -3818,9 +3903,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-9.0.2.tgz", - "integrity": "sha512-lxx/PibBfzqYvut2Y8N2D0Ritg9H8pKO+7NUSJb9YjR/bfk2KRmP8iaUz3zB0JhPtf/W3REs65oKpWxgflGToA==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.12.0.tgz", + "integrity": "sha512-rQHoxL0H0WwYUuukPUscLyzWwTl/hyogptYsY+Ye6AggJEOuvgJxMum2glY7etGIGOXxrfjareHnNO1tNY7WYg==", "cpu": [ "arm64" ], @@ -3831,10 +3916,38 @@ "linux" ] }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.12.0.tgz", + "integrity": "sha512-XPUZSctO+FrC0314Tcth+GrTtzy2yaYqyl8weBMAbKFMwuV8VnR2SHg9dmtI9vkukmM3auOLj0Kqjpl3YXwXiw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-9.0.2.tgz", - "integrity": "sha512-yD28ptS/OuNhwkpXRPNf+/FvrO7lwURLsEbRVcL1kIE0GxNJNMtKgIE4xQvtKDzkhk6ZRpLho5VSrkkF+3ARTQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.12.0.tgz", + "integrity": "sha512-AmMjcP+6zHLF1JNq/p3yPEcXmZW/Xw5Xl19Zd0eBCSyGORJRuUOkcnyC8bwMO43b/G7PtausB83fclnFL5KZ3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.12.0.tgz", + "integrity": "sha512-K2/yFBqFQOKyVwQxYDAKqDtk2kS4g58aGyj/R1bvYPr2P7v7971aUG/5m2WD5u2zSqWBfu1o4PdhX0lsqvA3vQ==", "cpu": [ "riscv64" ], @@ -3846,9 +3959,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-9.0.2.tgz", - "integrity": "sha512-WBwEJdspoga2w+aly6JVZeHnxuPVuztw3fPfWrei2P6rNM5hcKxBGWKKT6zO1fPMCB4sdDkFohGKkMHVV1eryQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.12.0.tgz", + "integrity": "sha512-uSl4jo78tONGZtwsOA4ldT/OI7/hoHJhSMlGYE4Z/lzwMjkAaBdX4soAK5P/rL+U2yCJlRMnnoUckhXlZvDbSw==", "cpu": [ "s390x" ], @@ -3860,9 +3973,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-9.0.2.tgz", - "integrity": "sha512-a2z3/cbOOTUq0UTBG8f3EO/usFcdwwXnCejfXv42HmV/G8GjrT4fp5+5mVDoMByH3Ce3iVPxj1LmS6OvItKMYQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.12.0.tgz", + "integrity": "sha512-YjL8VAkbPyQ1kUuR6pOBk1O+EkxOoLROTa+ia1/AmFLuXYNltLGI1YxOY14i80cKpOf0Z59IXnlrY3coAI9NDQ==", "cpu": [ "x64" ], @@ -3874,9 +3987,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-9.0.2.tgz", - "integrity": "sha512-bHZF+WShYQWpuswB9fyxcgMIWVk4sZQT0wnwpnZgQuvGTZLkYJ1JTCXJMtaX5mIFHf69ngvawnwPIUA4Feil0g==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.12.0.tgz", + "integrity": "sha512-qpHPU0qqeJXh7cPzA+I+WWA6RxtRArfmSrhTXidbiQ08G5A1e55YQwExWkitB2rSqN6YFxnpfhHKo9hyhpyfSg==", "cpu": [ "x64" ], @@ -3888,9 +4001,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-9.0.2.tgz", - "integrity": "sha512-I5cSgCCh5nFozGSHz+PjIOfrqW99eUszlxKLgoNNzQ1xQ2ou9ZJGzcZ94BHsM9SpyYHLtgHljmOZxCT9bgxYNA==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.12.0.tgz", + "integrity": "sha512-oqg80bERZAagWLqYmngnesE0/2miv4lST7+wiiZniD6gyb1SoRckwEkbTsytGutkudFtw7O61Pon6pNlOvyFaA==", "cpu": [ "wasm32" ], @@ -3898,16 +4011,16 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.9" + "@napi-rs/wasm-runtime": "^1.0.7" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-9.0.2.tgz", - "integrity": "sha512-5IhoOpPr38YWDWRCA5kP30xlUxbIJyLAEsAK7EMyUgqygBHEYLkElaKGgS0X5jRXUQ6l5yNxuW73caogb2FYaw==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.12.0.tgz", + "integrity": "sha512-qKH816ycEN9yR/TX91CP1/i6xyVNHKX9VEOYa3XzQROPVtcYG2F6A3ng/PhwpJvS1cmL/DlilhglZe9KWkhNjg==", "cpu": [ "arm64" ], @@ -3918,10 +4031,24 @@ "win32" ] }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.12.0.tgz", + "integrity": "sha512-3bgxubTlhzF6BwBnhGz5BTboarl1upuanEr6i0dncjfEcU+Z9xAOgbtA7Ip3G3EKDjE1objRKK+ny8PKJZ3b7Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-9.0.2.tgz", - "integrity": "sha512-Qc40GDkaad9rZksSQr2l/V9UubigIHsW69g94Gswc2sKYB3XfJXfIfyV8WTJ67u6ZMXsZ7BH1msSC6Aen75mCg==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.12.0.tgz", + "integrity": "sha512-rbiWYQWxwy+x7+KgNAoAGYIPB3xUclQlFVV3L5lwfsbp4PQPomJohHowlWgi3GRAEybM5+ZL9xny0YfpJOsthA==", "cpu": [ "x64" ], @@ -3944,18 +4071,18 @@ } }, "node_modules/@radix-ui/primitive": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", - "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz", - "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3973,17 +4100,17 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.8.tgz", - "integrity": "sha512-hxEsLvK9WxIAPyxdDRULL4hcaSjMZCfP7fHB0Z1uUnDoDBat1Zh46hwYfa69DeZAbJrPckjf0AGAtEZyvDyJbw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -4003,15 +4130,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", - "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-slot": "1.2.0" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4059,15 +4186,15 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.12.tgz", - "integrity": "sha512-5UFKuTMX8F2/KjHvyqu9IYT8bEtDSCJwwIx1PghBo4jh9S6jJVsceq9xIjqsOVcxsynGwV5eaqPE3n/Cu+DrSA==", + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.12", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, @@ -4087,22 +4214,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.11.tgz", - "integrity": "sha512-yI7S1ipkP5/+99qhSI6nthfo/tR6bL6Zgxi/+1UO6qPa6UeM6nlafWcQ65vB4rU2XjgjMfMhI3k9Y5MztA62VQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.7", - "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.6", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" @@ -4138,14 +4265,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz", - "integrity": "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, @@ -4165,17 +4292,17 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.12.tgz", - "integrity": "sha512-VJoMs+BWWE7YhzEQyVwvF9n22Eiyr83HotCVrMQzla/OwRovXCgah7AcaEr4hMNj4gJxSdtIbcHGvmJXOoJVHA==", + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.12", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { @@ -4194,9 +4321,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", - "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4209,13 +4336,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz", - "integrity": "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { @@ -4234,17 +4361,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.4.tgz", - "integrity": "sha512-97Q7Hb0///sMF2X8XvyVx3Aub7WG/ybIofoDVUo8utG/z/6TBzWGjgai7ZjECXYLbKip88t9/ibyQJvYe5k6SA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.4", - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4280,12 +4407,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz", - "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4303,26 +4430,26 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.12.tgz", - "integrity": "sha512-+qYq6LfbiGo97Zz9fioX83HCiIYYFNs8zAsVCMQrIakoNYylIzWuoD/anAD3UzvvR6cnswmfRFJFq/zYYq/k7Q==", + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.4", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.7", - "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.4", - "@radix-ui/react-portal": "1.1.6", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-roving-focus": "1.1.7", - "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" @@ -4343,16 +4470,16 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", - "integrity": "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.4", + "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", @@ -4375,12 +4502,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz", - "integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -4399,9 +4526,9 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", - "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -4423,12 +4550,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", - "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.0" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4446,13 +4573,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz", - "integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", "license": "MIT", "dependencies": { "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4470,18 +4597,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.7.tgz", - "integrity": "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.4", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, @@ -4501,12 +4628,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.4.tgz", - "integrity": "sha512-2fTm6PSiUm8YPq9W0E4reYuv01EE3aFSzt8edBiXqPHshF8N9+Kymt/k0/R+F3dkY5lQyB/zPtrP82phskLi7w==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4524,9 +4651,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", - "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -4676,16 +4803,16 @@ "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", - "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==", + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4706,9 +4833,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", - "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", + "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", "cpu": [ "arm" ], @@ -4720,9 +4847,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", - "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", + "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", "cpu": [ "arm64" ], @@ -4734,9 +4861,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", - "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", + "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", "cpu": [ "arm64" ], @@ -4748,9 +4875,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", - "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", + "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", "cpu": [ "x64" ], @@ -4762,9 +4889,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", - "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", + "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", "cpu": [ "arm64" ], @@ -4776,9 +4903,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", - "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", + "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", "cpu": [ "x64" ], @@ -4790,9 +4917,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", - "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", + "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", "cpu": [ "arm" ], @@ -4804,9 +4931,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", - "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", + "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", "cpu": [ "arm" ], @@ -4818,9 +4945,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", - "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", + "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", "cpu": [ "arm64" ], @@ -4832,9 +4959,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", - "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", + "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", "cpu": [ "arm64" ], @@ -4846,9 +4973,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", - "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", + "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", "cpu": [ "loong64" ], @@ -4859,10 +4986,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", - "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", + "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", "cpu": [ "ppc64" ], @@ -4874,9 +5001,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", - "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", + "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", "cpu": [ "riscv64" ], @@ -4888,9 +5015,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", - "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", + "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", "cpu": [ "riscv64" ], @@ -4902,9 +5029,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", - "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", + "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", "cpu": [ "s390x" ], @@ -4916,9 +5043,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", - "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", + "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", "cpu": [ "x64" ], @@ -4930,9 +5057,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", - "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", + "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", "cpu": [ "x64" ], @@ -4943,10 +5070,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", + "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", - "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", + "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", "cpu": [ "arm64" ], @@ -4958,9 +5099,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", - "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", + "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", "cpu": [ "ia32" ], @@ -4972,9 +5113,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", - "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", + "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", "cpu": [ "x64" ], @@ -4993,16 +5134,16 @@ "license": "Apache-2.0" }, "node_modules/@storybook/addon-docs": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.0.8.tgz", - "integrity": "sha512-YRR8qHitwXVTJyn02YMrzd9mCKcuZWSKWt+J/ddFb8khGfLcAW+O0NohGeqMyM6XStLVDKKIKsMoTHggUwIFsA==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.13.tgz", + "integrity": "sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "9.0.8", - "@storybook/icons": "^1.2.12", - "@storybook/react-dom-shim": "9.0.8", + "@storybook/csf-plugin": "9.1.13", + "@storybook/icons": "^1.4.0", + "@storybook/react-dom-shim": "9.1.13", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -5012,17 +5153,17 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^9.0.8" + "storybook": "^9.1.13" } }, "node_modules/@storybook/builder-vite": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.0.8.tgz", - "integrity": "sha512-dbwDfBUwLW8I71h0Y9r+twtEdjKC+oBP1YweS26ET78qc6qXMFsK5Tfh6lzj/vQbmxY0YhnTVrrkXgfR0erLPA==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.13.tgz", + "integrity": "sha512-pmtIjU02ASJOZKdL8DoxWXJgZnpTDgD5WmMnjKJh9FaWmc2YiCW2Y6VRxPox96OM655jYHQe5+UIbk3Cwtwb4A==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "9.0.8", + "@storybook/csf-plugin": "9.1.13", "ts-dedent": "^2.0.0" }, "funding": { @@ -5030,14 +5171,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^9.0.8", - "vite": "^5.0.0 || ^6.0.0" + "storybook": "^9.1.13", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.0.8.tgz", - "integrity": "sha512-mNjo4t9liAbQvhE9ni87NU2sz9tqFU4Y54ioSFDlW24wpubsvnhBi5h4z3EkeQJSzIzNMRym9SC7Elbqa3Kf+g==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.13.tgz", + "integrity": "sha512-EMpzYuyt9FDcxxfBChWzfId50y8QMpdenviEQ8m+pa6c+ANx3pC5J6t7y0khD8TQu815sTy+nc6cc8PC45dPUA==", "dev": true, "license": "MIT", "dependencies": { @@ -5048,7 +5189,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^9.0.8" + "storybook": "^9.1.13" } }, "node_modules/@storybook/global": { @@ -5073,14 +5214,14 @@ } }, "node_modules/@storybook/react": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.0.8.tgz", - "integrity": "sha512-in3O+lDmxKRhdcX3Wg6FbLnb2/PuqRL+rUKMz1wr1ndSkw4J1jGsvP909oEEYnDbjHOX0xnNxxbEapO4F9fgBQ==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.13.tgz", + "integrity": "sha512-B0UpYikKf29t8QGcdmumWojSQQ0phSDy/Ne2HYdrpNIxnUvHHUVOlGpq4lFcIDt52Ip5YG5GuAwJg3+eR4LCRg==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "9.0.8" + "@storybook/react-dom-shim": "9.1.13" }, "engines": { "node": ">=20.0.0" @@ -5092,7 +5233,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^9.0.8", + "storybook": "^9.1.13", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -5102,9 +5243,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.0.8.tgz", - "integrity": "sha512-SYyjRagHZx724hGEWSZcXRzj82am77OpqeA9ps6ZsCSn4cVY9FORGEeY2bnlQkpLnDUH5yjdV/oh+0fXDbl/8g==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.13.tgz", + "integrity": "sha512-/tMr9TmV3+98GEQO0S03k4gtKHGCpv9+k9Dmnv+TJK3TBz7QsaFEzMwe3gCgoTaebLACyVveDiZkWnCYAWB6NA==", "dev": true, "license": "MIT", "funding": { @@ -5114,21 +5255,21 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^9.0.8" + "storybook": "^9.1.13" } }, "node_modules/@storybook/react-vite": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.0.8.tgz", - "integrity": "sha512-nAtT9UeOkKCBJ2kbatC7Hf/TX3Kl+e21wvc1D5xpS86ulPamzjzxLD5nW+vliBKePOo+9ZW/KQZYCLg3snRJtQ==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.13.tgz", + "integrity": "sha512-mV1bZ1bpkNQygnuDo1xMGAS5ZXuoXFF0WGmr/BzNDGmRhZ1K1HQh42kC0w3PklckFBUwCFxmP58ZwTFzf+/dJA==", "dev": true, "license": "MIT", "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.0", + "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "9.0.8", - "@storybook/react": "9.0.8", - "find-up": "^5.0.0", + "@storybook/builder-vite": "9.1.13", + "@storybook/react": "9.1.13", + "find-up": "^7.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", "resolve": "^1.22.8", @@ -5144,14 +5285,14 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^9.0.8", - "vite": "^5.0.0 || ^6.0.0" + "storybook": "^9.1.13", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@tanstack/history": { - "version": "1.120.17", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.120.17.tgz", - "integrity": "sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==", + "version": "1.131.2", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.131.2.tgz", + "integrity": "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw==", "license": "MIT", "engines": { "node": ">=12" @@ -5162,9 +5303,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.80.6", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.6.tgz", - "integrity": "sha512-nl7YxT/TAU+VTf+e2zTkObGTyY8YZBMnbgeA1ee66lIVqzKlYursAII6z5t0e6rXgwUMJSV4dshBTNacNpZHbQ==", + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", + "integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==", "license": "MIT", "funding": { "type": "github", @@ -5172,9 +5313,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.80.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.80.0.tgz", - "integrity": "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA==", + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", + "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", "dev": true, "license": "MIT", "funding": { @@ -5183,12 +5324,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.80.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.6.tgz", - "integrity": "sha512-izX+5CnkpON3NQGcEm3/d7LfFQNo9ZpFtX2QsINgCYK9LT2VCIdi8D3bMaMSNhrAJCznRoAkFic76uvLroALBw==", + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz", + "integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.80.6" + "@tanstack/query-core": "5.90.5" }, "funding": { "type": "github", @@ -5199,33 +5340,33 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.80.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.80.6.tgz", - "integrity": "sha512-y7Es0OJ4RYQxrPYsuuQP0jxjgJ40a03UbEPmJ6vwf/ERVMRoRIMkpjtvPxf1D+n9nwPfWmGdD0jW8Wxd+TxeEw==", + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", + "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.80.0" + "@tanstack/query-devtools": "5.90.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.80.6", + "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "node_modules/@tanstack/react-router": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.121.0.tgz", - "integrity": "sha512-l+hwNPzAPuCb/V4K6E1ZwKplnk8/nYUTQrXdtfXro5xOT1zNedJOm+juZsNKyoHokvhH+uT+I1s+mrkaykctcA==", + "version": "1.131.44", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.44.tgz", + "integrity": "sha512-LREJfrl8lSedXHCRAAt0HvnHFP9ikAQWnVhYRM++B26w4ZYQBbLvgCT1BCDZVY7MR6rslcd4OfgpZMOyVhNzFg==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.120.17", + "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", - "@tanstack/router-core": "1.121.0", - "jsesc": "^3.1.0", + "@tanstack/router-core": "1.131.44", + "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, @@ -5242,13 +5383,36 @@ } }, "node_modules/@tanstack/react-router-devtools": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.121.0.tgz", - "integrity": "sha512-428xI3N40MEdGZ0Y7WLJVBpezLhG/mNZX92OEx3r0E183X1hoooi2/s9+JoF4ifDv1muoRX6oZqOqGhs4BqeRg==", + "version": "1.131.44", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.131.44.tgz", + "integrity": "sha512-JGICSLe3ZIqayo2Pz9bpCBLrK8NIruYSQoe/JkZimSGltV3HU+uPb1dohw0CpyxVuhx+tDqFBzq4cDPCABs4/w==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/router-devtools-core": "^1.121.0", + "@tanstack/router-devtools-core": "1.131.44" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.131.44", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools/node_modules/@tanstack/router-devtools-core": { + "version": "1.131.44", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.131.44.tgz", + "integrity": "sha512-ZpQfRERLAjZ2NBdFOWjlrbMzQ+23aGs+9324KVdLzZkcd1lc0ztpLb5HAGtqLXfncvO60TfiRz106ygjKsaJow==", + "dev": true, + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16", "solid-js": "^1.9.5" }, "engines": { @@ -5259,19 +5423,25 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.121.0", - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" + "@tanstack/router-core": "^1.131.44", + "csstype": "^3.0.10", + "solid-js": ">=1.9.5", + "tiny-invariant": "^1.3.3" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } } }, "node_modules/@tanstack/react-store": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.0.tgz", - "integrity": "sha512-S/Rq17HaGOk+tQHV/yrePMnG1xbsKZIl/VsNWnNXt4XW+tTY8dTlvpJH2ZQ3GRALsusG5K6Q3unAGJ2pd9W/Ng==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.5.tgz", + "integrity": "sha512-A+WZtEnHZpvbKXm8qR+xndNKywBLez2KKKKEQc7w0Qs45GvY1LpRI3BTZNmELwEVim8+Apf99iEDH2J+MUIzlQ==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.7.0", - "use-sync-external-store": "^1.4.0" + "@tanstack/store": "0.7.5", + "use-sync-external-store": "^1.5.0" }, "funding": { "type": "github", @@ -5283,14 +5453,18 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.121.0.tgz", - "integrity": "sha512-EMiLgRMHbi1JHOgJOrxUSc3Ws+Jge3bGa7r03tdIgvH07dJcsY03L5ZLtuqYKvuS6YJvYeciEXA31IUIEN9dVA==", + "version": "1.131.44", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.131.44.tgz", + "integrity": "sha512-Npi9xB3GSYZhRW8+gPhP6bEbyx0vNc8ZNwsi0JapdiFpIiszgRJ57pesy/rklruv46gYQjLVA5KDOsuaCT/urA==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.120.17", + "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", - "tiny-invariant": "^1.3.3" + "cookie-es": "^1.2.2", + "seroval": "^1.3.2", + "seroval-plugins": "^1.3.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" }, "engines": { "node": ">=12" @@ -5300,45 +5474,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/router-devtools-core": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.121.0.tgz", - "integrity": "sha512-66+bkdII8j3DJWyOgnJpmuddkt+0aVSFI5UP1G6i7qUrqZAciWXBD+UlZJ5pt6S/7PVBow7xsd0YzMEZlz+lIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/router-core": "^1.121.0", - "csstype": "^3.0.10", - "solid-js": ">=1.9.5", - "tiny-invariant": "^1.3.3" - }, - "peerDependenciesMeta": { - "csstype": { - "optional": true - } - } - }, "node_modules/@tanstack/router-generator": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.121.0.tgz", - "integrity": "sha512-3JDHlL5mrdVI6RwRf92YId0fgYr6Vdh0yo/pYMUV6USkB0fRtayz176AODvTaR5IeB2BYkaiPKTK38ySyGueRw==", + "version": "1.131.44", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.131.44.tgz", + "integrity": "sha512-CnrlRkGatdQXdvTteflOTMANupb1z59CO3DSV+UzBkTG+g+vfWgJeKQ0EkfwZ2QuS6Su2v5r5EMHs/AookeZZw==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/router-core": "^1.121.0", - "@tanstack/router-utils": "^1.121.0", - "@tanstack/virtual-file-routes": "^1.120.17", + "@tanstack/router-core": "1.131.44", + "@tanstack/router-utils": "1.131.2", + "@tanstack/virtual-file-routes": "1.131.2", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", @@ -5353,33 +5498,23 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/router-generator/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, "node_modules/@tanstack/router-plugin": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.121.0.tgz", - "integrity": "sha512-A05CE2JuebgU+D7H4VHhOkWDmW1NxFbKVq82hHewiBWdq2nwODSZQV4AO+Ub7bIcILHYX/ivKuspK11s/d7k0A==", + "version": "1.131.44", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.131.44.tgz", + "integrity": "sha512-CvheUPlB8vxXf23RSDz6q97l1EI5H3f+1qJ/LEBvy7bhls8vYouJ3xyTeu4faz8bEEieLUoVQrCcr+xFY0lkuw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.8", - "@babel/plugin-syntax-jsx": "^7.25.9", - "@babel/plugin-syntax-typescript": "^7.25.9", - "@babel/template": "^7.26.8", - "@babel/traverse": "^7.26.8", - "@babel/types": "^7.26.8", - "@tanstack/router-core": "^1.121.0", - "@tanstack/router-generator": "^1.121.0", - "@tanstack/router-utils": "^1.121.0", - "@tanstack/virtual-file-routes": "^1.120.17", + "@babel/core": "^7.27.7", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", + "@tanstack/router-core": "1.131.44", + "@tanstack/router-generator": "1.131.44", + "@tanstack/router-utils": "1.131.2", + "@tanstack/virtual-file-routes": "1.131.2", "babel-dead-code-elimination": "^1.0.10", "chokidar": "^3.6.0", "unplugin": "^2.1.2", @@ -5394,7 +5529,7 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", - "@tanstack/react-router": "^1.121.0", + "@tanstack/react-router": "^1.131.44", "vite": ">=5.0.0 || >=6.0.0", "vite-plugin-solid": "^2.11.2", "webpack": ">=5.92.0" @@ -5418,14 +5553,15 @@ } }, "node_modules/@tanstack/router-plugin/node_modules/unplugin": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.2.tgz", - "integrity": "sha512-3n7YA46rROb3zSj8fFxtxC/PqoyvYQ0llwz9wtUPUutr9ig09C8gGo5CWCwHrUzlqC1LLR43kxp5vEIyH1ac1w==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz", + "integrity": "sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.14.1", - "picomatch": "^4.0.2", + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" }, "engines": { @@ -5433,9 +5569,9 @@ } }, "node_modules/@tanstack/router-utils": { - "version": "1.121.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.121.0.tgz", - "integrity": "sha512-+gOHZdEVjOTTdk8Z7J/NVG0KdvzxFeUYjINYZEqQDRKoxEg8f+Npram0MXGy8N15OyZrsm+KHR1vMFZ2yEvZkw==", + "version": "1.131.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.131.2.tgz", + "integrity": "sha512-sr3x0d2sx9YIJoVth0QnfEcAcl+39sQYaNQxThtHmRpyeFYNyM2TTH+Ud3TNEnI3bbzmLYEUD+7YqB987GzhDA==", "dev": true, "license": "MIT", "dependencies": { @@ -5455,9 +5591,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.0.tgz", - "integrity": "sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.5.tgz", + "integrity": "sha512-qd/OjkjaFRKqKU4Yjipaen/EOB9MyEg6Wr9fW103RBPACf1ZcKhbhcu2S5mj5IgdPib6xFIgCUti/mKVkl+fRw==", "license": "MIT", "funding": { "type": "github", @@ -5465,9 +5601,9 @@ } }, "node_modules/@tanstack/virtual-file-routes": { - "version": "1.120.17", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.120.17.tgz", - "integrity": "sha512-Ssi+yKcjG9ru02ieCpUBF7QQBEKGB7WQS1R9va3GHu+Oq9WjzmJ4rifzdugjTeKD3yfT7d1I+pOxRhoWog6CHw==", + "version": "1.131.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz", + "integrity": "sha512-VEEOxc4mvyu67O+Bl0APtYjwcNRcL9it9B4HKbNgcBTIOEalhk+ufBl4kiqc8WP1sx1+NAaiS+3CcJBhrqaSRg==", "dev": true, "license": "MIT", "engines": { @@ -5479,9 +5615,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "peer": true, @@ -5490,9 +5626,9 @@ "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", - "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { @@ -5500,18 +5636,17 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", - "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", - "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", + "picocolors": "^1.1.1", "redent": "^3.0.0" }, "engines": { @@ -5520,20 +5655,6 @@ "yarn": ">=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -5583,10 +5704,29 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@theguild/federation-composition": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.19.1.tgz", + "integrity": "sha512-E4kllHSRYh+FsY0VR+fwl0rmWhDV8xUgWawLZTXmy15nCWQwj0BDsoEpdEXjPh7xes+75cRaeJcSbZ4jkBuSdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "constant-case": "^3.0.4", + "debug": "4.4.1", + "json5": "^2.2.3", + "lodash.sortby": "^4.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "graphql": "^16.0.0" + } + }, "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -5638,13 +5778,13 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/chai": { @@ -5657,13 +5797,6 @@ "@types/deep-eql": "*" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -5679,16 +5812,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -5707,19 +5833,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz", - "integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==", + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/react": { - "version": "19.1.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.7.tgz", - "integrity": "sha512-BnsPLV43ddr05N71gaGzyZ5hzkCmGwhMvYc8zmvI8Ci1bRkkDSzDDVfAXfN2tk748OwI7ediiPX6PfT9p0QGVg==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5727,13 +5853,13 @@ } }, "node_modules/@types/react-dom": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", - "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/resolve": { @@ -5744,16 +5870,16 @@ "license": "MIT" }, "node_modules/@types/statuses": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", - "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true, "license": "MIT" }, "node_modules/@types/swagger-ui-dist": { - "version": "3.30.5", - "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.5.tgz", - "integrity": "sha512-SrXhD9L8qeIxJzN+o1kmf3wXeVf/+Km3jIdRM1+Yq3I5b/dlF5TcGr5WCVM7I/cBYpgf43/gCPIucQ13AhICiw==", + "version": "3.30.6", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.6.tgz", + "integrity": "sha512-FVxN7wjLYRtJsZBscOcOcf8oR++m38vbUFjT33Mr9HBuasX9bRDrJsp7iwixcOtKSHEEa2B7o2+4wEiXqC+Ebw==", "dev": true, "license": "MIT" }, @@ -5764,10 +5890,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", "dev": true, "license": "MIT" }, @@ -5782,9 +5908,9 @@ } }, "node_modules/@vector-im/compound-design-tokens": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.2.tgz", - "integrity": "sha512-y13bhPyJ5OzbGRl21F6+Y2adrjyK+mu67yKTx+o8MfmIpJzMSn4KkHZtcujMquWSh0e5ZAufsnk4VYvxbSpr1A==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-6.0.0.tgz", + "integrity": "sha512-Jk0NsLPCvdcuZi6an1cfyf4MDcIuoPlvja5ZWgJcORyGQZV1eLMHPYKShq9gj+EYk/BXZoPvQ1d6/T+/LSCNPA==", "license": "SEE LICENSE IN README.md", "peerDependencies": { "@types/react": "*", @@ -5800,13 +5926,13 @@ } }, "node_modules/@vector-im/compound-web": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/@vector-im/compound-web/-/compound-web-7.12.0.tgz", - "integrity": "sha512-eGsJhouuBJKnh53yOfcakithgFUzaviDR9dWnIs3ULMK97D+XkXe82M/ULg+dFwbTxHKcnIfy3V7H1pcEjFVcQ==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@vector-im/compound-web/-/compound-web-8.2.4.tgz", + "integrity": "sha512-Fsb/99r98ICMdtNt/bFcWtLmjyngcye9Ugqm9VDapo1VnrO0wLFJHOcm+J+SvKZwTXCaSndBklygAr5FXk0E9w==", "license": "SEE LICENSE IN README.md", "dependencies": { "@floating-ui/react": "^0.27.0", - "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-form": "^0.1.0", "@radix-ui/react-progress": "^1.1.0", @@ -5819,7 +5945,7 @@ "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@types/react": "*", - "@vector-im/compound-design-tokens": ">=1.6.1 <5.0.0", + "@vector-im/compound-design-tokens": ">=1.6.1 <7.0.0", "react": "^18 || ^19.0.0" }, "peerDependenciesMeta": { @@ -5829,30 +5955,30 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz", - "integrity": "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.11", + "@rolldown/pluginutils": "1.0.0-beta.43", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.3.tgz", - "integrity": "sha512-D1QKzngg8PcDoCE8FHSZhREDuEy+zcKmMiMafYse41RZpBE5EDJyKOTdqK3RQfsV2S2nyKor5KCs8PyPRFqKPg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5874,8 +6000,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.3", - "vitest": "3.2.3" + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5884,15 +6010,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz", - "integrity": "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.3", - "@vitest/utils": "3.2.3", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -5901,13 +6027,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.3.tgz", - "integrity": "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.3", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -5938,9 +6064,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz", - "integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -5951,13 +6077,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.3.tgz", - "integrity": "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.3", + "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -5966,13 +6092,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.3.tgz", - "integrity": "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.3", + "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -5981,9 +6107,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.3.tgz", - "integrity": "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { @@ -5993,25 +6119,15 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/spy/node_modules/tinyspy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", - "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@vitest/utils": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.3.tgz", - "integrity": "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.3", - "loupe": "^3.1.3", + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, "funding": { @@ -6033,13 +6149,13 @@ } }, "node_modules/@whatwg-node/fetch": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.6.tgz", - "integrity": "sha512-6uzhO2aQ757p3bSHcemA8C4pqEXuyBqyGAM7cYpO0c6/igRMV9As9XL0W12h5EPYMclgr7FgjmbVQBoWEdJ/yA==", + "version": "0.10.10", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.10.tgz", + "integrity": "sha512-watz4i/Vv4HpoJ+GranJ7HH75Pf+OkPQ63NoVmru6Srgc8VezTArB00i/oQlnn0KWh14gM42F22Qcc9SU9mo/w==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/node-fetch": "^0.7.18", + "@whatwg-node/node-fetch": "^0.7.25", "urlpattern-polyfill": "^10.0.0" }, "engines": { @@ -6047,15 +6163,15 @@ } }, "node_modules/@whatwg-node/node-fetch": { - "version": "0.7.18", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.18.tgz", - "integrity": "sha512-IxKdVWfZYasGiyxBcsROxq6FmDQu3MNNiOYJ/yqLKhe+Qq27IIWsK7ItbjS2M9L5aM5JxjWkIS7JDh7wnsn+CQ==", + "version": "0.7.25", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.25.tgz", + "integrity": "sha512-szCTESNJV+Xd56zU6ShOi/JWROxE9IwCic8o5D9z5QECZloas6Ez5tUuKqXTAdu6fHFx1t6C+5gwj8smzOLjtg==", "dev": true, "license": "MIT", "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.3.1", + "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" }, "engines": { @@ -6063,9 +6179,9 @@ } }, "node_modules/@whatwg-node/promise-helpers": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.1.tgz", - "integrity": "sha512-D+OwTEunoQhVHVToD80dPhfz9xgPLqJyEA3F5jCRM14A2u8tBBQVdZekqfqx6ZAfZ+POT4Hb0dn601UKMsvADw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", "dependencies": { @@ -6091,9 +6207,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -6103,41 +6219,17 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.0.tgz", + "integrity": "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "environment": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6228,9 +6320,9 @@ "license": "Python-2.0" }, "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -6290,13 +6382,13 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz", - "integrity": "sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz", + "integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.30", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } @@ -6318,16 +6410,6 @@ "dev": true, "license": "MIT" }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/auto-bind": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", @@ -6380,11 +6462,19 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.1.tgz", + "integrity": "sha512-ZovbrBV0g6JxK5cGUF1Suby1vLfKjv4RWi8IxoaO/Mon8BDD9I21RxjHFtgQ+kskJqLAVyQZly3uMBui+vhc8Q==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/babel-dead-code-elimination": { "version": "1.0.10", @@ -6453,9 +6543,9 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", + "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==", "dev": true, "license": "Apache-2.0", "optional": true @@ -6481,12 +6571,15 @@ ], "license": "MIT" }, - "node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "node_modules/baseline-browser-mapping": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.2.tgz", + "integrity": "sha512-NvcIedLxrs9llVpX7wI+Jz4Hn9vJQkCPKrTaHIE0sW/Rj1iq6Fzby4NbyTZjQJNoypBXNaG7tEHkTgONZpwgxQ==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } }, "node_modules/better-opn": { "version": "3.0.2", @@ -6514,18 +6607,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -6534,9 +6615,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6643,9 +6724,9 @@ } }, "node_modules/broccoli-plugin/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6706,9 +6787,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", + "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", "dev": true, "funding": [ { @@ -6726,10 +6807,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.8.2", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -6767,31 +6849,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6844,9 +6901,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001716", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001716.tgz", - "integrity": "sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -6877,9 +6934,9 @@ } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { @@ -6890,7 +6947,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -6951,9 +7008,9 @@ } }, "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", "dev": true, "license": "MIT" }, @@ -6968,26 +7025,26 @@ } }, "node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", "dev": true, "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", + "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">=18.17" + "node": ">=20.18.1" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -7011,16 +7068,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cheerio/node_modules/undici": { - "version": "6.21.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", - "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/cheerio/node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -7062,67 +7109,93 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", + "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "license": "ISC", "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/cliui": { @@ -7168,13 +7241,6 @@ "node": ">=0.8" } }, - "node_modules/clone-stats": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", - "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", - "dev": true, - "license": "MIT" - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -7269,15 +7335,21 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7351,9 +7423,9 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7368,9 +7440,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -7435,11 +7507,17 @@ } }, "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", + "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/debug": { "version": "4.4.1", @@ -7479,29 +7557,6 @@ "node": ">=6" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -7522,13 +7577,6 @@ "node": ">= 0.6.0" } }, - "node_modules/deprecation": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", - "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", - "dev": true, - "license": "ISC" - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -7683,19 +7731,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -7714,9 +7749,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.145", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.145.tgz", - "integrity": "sha512-pZ5EcTWRq/055MvSBgoFEyKf2i4apwfoqJbK/ak2jnFq8oHjZ+vzc3AhRcz37Xn+ZJfL58R666FLJx0YOK9yTw==", + "version": "1.5.218", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", + "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", "dev": true, "license": "ISC" }, @@ -7728,9 +7763,9 @@ "license": "MIT" }, "node_modules/encoding-sniffer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", - "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "dev": true, "license": "MIT", "dependencies": { @@ -7761,6 +7796,29 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eol": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz", @@ -7786,9 +7844,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", - "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7799,31 +7857,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.3", - "@esbuild/android-arm": "0.25.3", - "@esbuild/android-arm64": "0.25.3", - "@esbuild/android-x64": "0.25.3", - "@esbuild/darwin-arm64": "0.25.3", - "@esbuild/darwin-x64": "0.25.3", - "@esbuild/freebsd-arm64": "0.25.3", - "@esbuild/freebsd-x64": "0.25.3", - "@esbuild/linux-arm": "0.25.3", - "@esbuild/linux-arm64": "0.25.3", - "@esbuild/linux-ia32": "0.25.3", - "@esbuild/linux-loong64": "0.25.3", - "@esbuild/linux-mips64el": "0.25.3", - "@esbuild/linux-ppc64": "0.25.3", - "@esbuild/linux-riscv64": "0.25.3", - "@esbuild/linux-s390x": "0.25.3", - "@esbuild/linux-x64": "0.25.3", - "@esbuild/netbsd-arm64": "0.25.3", - "@esbuild/netbsd-x64": "0.25.3", - "@esbuild/openbsd-arm64": "0.25.3", - "@esbuild/openbsd-x64": "0.25.3", - "@esbuild/sunos-x64": "0.25.3", - "@esbuild/win32-arm64": "0.25.3", - "@esbuild/win32-ia32": "0.25.3", - "@esbuild/win32-x64": "0.25.3" + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" } }, "node_modules/esbuild-register": { @@ -7849,16 +7908,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -7890,44 +7939,23 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -8005,21 +8033,24 @@ "license": "MIT" }, "node_modules/fd-package-json": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-1.2.0.tgz", - "integrity": "sha512-45LSPmWf+gC5tdCQMNH4s9Sr00bIkiD9aN7dc5hqkrEw1geRYyDQS1v1oMHAW3ysfxfndqGsrDREHHjNNbKUfA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", "dev": true, "license": "MIT", "dependencies": { - "walk-up-path": "^3.0.1" + "walk-up-path": "^4.0.0" } }, "node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -8053,22 +8084,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8083,17 +8098,18 @@ } }, "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8117,13 +8133,13 @@ } }, "node_modules/formatly": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.2.3.tgz", - "integrity": "sha512-WH01vbXEjh9L3bqn5V620xUAWs32CmK4IzWRRY6ep5zpa/mrisL4d9+pRVuETORVDTQw8OycSO1WC68PL51RaA==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", "dev": true, "license": "MIT", "dependencies": { - "fd-package-json": "^1.2.0" + "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" @@ -8160,9 +8176,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "dev": true, "license": "MIT", "dependencies": { @@ -8306,6 +8322,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -8316,9 +8345,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", - "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8363,9 +8392,9 @@ } }, "node_modules/glob-stream": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.2.tgz", - "integrity": "sha512-R8z6eTB55t3QeZMmU1C+Gv+t5UnNRkA55c5yo67fAVfxODxieTwsjNG7utxS/73NdP1NbDgCrhVEg2h00y4fFw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz", + "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==", "dev": true, "license": "MIT", "dependencies": { @@ -8395,16 +8424,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -8486,29 +8505,15 @@ } }, "node_modules/graphql-config/node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/graphql-request": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", - "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "cross-fetch": "^3.1.5" - }, - "peerDependencies": { - "graphql": "14 - 16" - } - }, "node_modules/graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", @@ -8526,9 +8531,9 @@ } }, "node_modules/graphql-ws": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.4.tgz", - "integrity": "sha512-8b4OZtNOvv8+NZva8HXamrc0y1jluYC0+13gdh7198FKjVzXyTvVc95DCwGzaKEfn3YuWZxUqjJlHe3qKM/F2g==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.6.tgz", + "integrity": "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==", "dev": true, "license": "MIT", "engines": { @@ -8536,6 +8541,7 @@ }, "peerDependencies": { "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "uWebSockets.js": "^20", "ws": "^8" @@ -8544,6 +8550,9 @@ "@fastify/websocket": { "optional": true }, + "crossws": { + "optional": true + }, "uWebSockets.js": { "optional": true }, @@ -8563,19 +8572,37 @@ } }, "node_modules/happy-dom": { - "version": "17.6.3", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.6.3.tgz", - "integrity": "sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==", + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.4.tgz", + "integrity": "sha512-WxFtvnij6G64/MtMimnZhF0nKx3LUQKc20zjATD6tKiqOykUwQkd+2FW/DZBAFNjk4oWh0xdv/HBleGJmSY/Iw==", "dev": true, "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0", + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz", + "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8679,9 +8706,9 @@ } }, "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -8694,42 +8721,27 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" + "domutils": "^3.2.1", + "entities": "^6.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "node": ">=0.12" }, - "engines": { - "node": ">= 14" + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/i18next": { - "version": "25.2.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz", - "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.0.tgz", + "integrity": "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==", "funding": [ { "type": "individual", @@ -8746,7 +8758,7 @@ ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1" + "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" @@ -8757,39 +8769,6 @@ } } }, - "node_modules/i18next-browser-languagedetector": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.5.tgz", - "integrity": "sha512-OstebRKqKiQw8xEvQF5aRyUujsCatanj7Q9eo5iiH2gJpoXGZ7483ol3sVBwfqbobTQPNH1J+NAyJ1aCQoEC+w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.2" - } - }, - "node_modules/i18next-http-backend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", - "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cross-fetch": "4.0.0" - } - }, - "node_modules/i18next-http-backend/node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/i18next-parser": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.3.0.tgz", @@ -8979,33 +8958,6 @@ "dev": true, "license": "ISC" }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -9115,16 +9067,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", @@ -9264,6 +9206,15 @@ "dev": true, "license": "MIT" }, + "node_modules/isbot": { + "version": "5.1.30", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.30.tgz", + "integrity": "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9322,9 +9273,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9361,16 +9312,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9395,6 +9336,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -9438,9 +9380,9 @@ } }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { @@ -9451,9 +9393,9 @@ } }, "node_modules/knip": { - "version": "5.59.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.59.1.tgz", - "integrity": "sha512-pOMBw6sLQhi/RfnpI6TwBY6NrAtKXDO5wkmMm+pCsSK5eWbVfDnDtPXbLDGNCoZPXiuAojb27y4XOpp4JPNxlA==", + "version": "5.66.4", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.66.4.tgz", + "integrity": "sha512-HmTnxdmoHAvwKmFktRGY1++tXRI8J36eVrOpfj/ybTVVT1QBKBlbBEN1s3cJBx9UL+hXTZDNQif+gs7fUKldbw==", "dev": true, "funding": [ { @@ -9463,27 +9405,22 @@ { "type": "opencollective", "url": "https://opencollective.com/knip" - }, - { - "type": "polar", - "url": "https://polar.sh/webpro-nl" } ], "license": "ISC", "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", - "formatly": "^0.2.3", - "jiti": "^2.4.2", + "formatly": "^0.3.0", + "jiti": "^2.6.0", "js-yaml": "^4.1.0", "minimist": "^1.2.8", - "oxc-resolver": "^9.0.2", - "picocolors": "^1.1.0", + "oxc-resolver": "^11.12.0", + "picocolors": "^1.1.1", "picomatch": "^4.0.1", - "smol-toml": "^1.3.1", - "strip-json-comments": "5.0.1", - "zod": "^3.22.4", - "zod-validation-error": "^3.0.3" + "smol-toml": "^1.4.1", + "strip-json-comments": "5.0.2", + "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", @@ -9494,19 +9431,29 @@ }, "peerDependencies": { "@types/node": ">=18", - "typescript": ">=5.0.4" + "typescript": ">=5.0.4 <7" } }, "node_modules/knip/node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/knip/node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/lead": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz", @@ -9538,62 +9485,119 @@ "license": "MIT" }, "node_modules/listr2": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz", - "integrity": "sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", + "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.5", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" }, "engines": { "node": ">=12" }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" - }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "p-locate": "^6.0.0" }, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9631,40 +9635,108 @@ } }, "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/loose-envify": { @@ -9681,9 +9753,9 @@ } }, "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, "license": "MIT" }, @@ -9729,13 +9801,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { @@ -9766,6 +9838,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", @@ -9791,9 +9876,9 @@ } }, "node_modules/matcher-collection/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -9838,9 +9923,9 @@ } }, "node_modules/meros": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.0.tgz", - "integrity": "sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.2.tgz", + "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==", "dev": true, "license": "MIT", "engines": { @@ -9882,14 +9967,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/min-indent": { @@ -9956,30 +10044,30 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.8.7.tgz", - "integrity": "sha512-0TGfV4oQiKpa3pDsQBDf0xvFP+sRrqEOnh2n1JWpHVKHJHLv6ZmY1HCZpCi7uDiJTeIHJMBpmBiRmBJN+ETPSQ==", + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.6.tgz", + "integrity": "sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", - "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.38.7", + "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", + "until-async": "^3.0.2", "yargs": "^17.7.2" }, "bin": { @@ -10014,9 +10102,9 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.1.tgz", - "integrity": "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -10027,11 +10115,14 @@ } }, "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/mz": { "version": "2.7.0", @@ -10125,9 +10216,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "dev": true, "license": "MIT" }, @@ -10215,16 +10306,16 @@ } }, "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10248,40 +10339,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -10290,28 +10347,34 @@ "license": "MIT" }, "node_modules/oxc-resolver": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-9.0.2.tgz", - "integrity": "sha512-w838ygc1p7rF+7+h5vR9A+Y9Fc4imy6C3xPthCMkdFUgFvUWkmABeNB8RBDQ6+afk44Q60/UMMQ+gfDUW99fBA==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.12.0.tgz", + "integrity": "sha512-zmS2q2txiB+hS2u0aiIwmvITIJN8c8ThlWoWB762Wx5nUw8WBlttp0rzt8nnuP1cGIq9YJ7sGxfsgokm+SQk5Q==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-darwin-arm64": "9.0.2", - "@oxc-resolver/binding-darwin-x64": "9.0.2", - "@oxc-resolver/binding-freebsd-x64": "9.0.2", - "@oxc-resolver/binding-linux-arm-gnueabihf": "9.0.2", - "@oxc-resolver/binding-linux-arm64-gnu": "9.0.2", - "@oxc-resolver/binding-linux-arm64-musl": "9.0.2", - "@oxc-resolver/binding-linux-riscv64-gnu": "9.0.2", - "@oxc-resolver/binding-linux-s390x-gnu": "9.0.2", - "@oxc-resolver/binding-linux-x64-gnu": "9.0.2", - "@oxc-resolver/binding-linux-x64-musl": "9.0.2", - "@oxc-resolver/binding-wasm32-wasi": "9.0.2", - "@oxc-resolver/binding-win32-arm64-msvc": "9.0.2", - "@oxc-resolver/binding-win32-x64-msvc": "9.0.2" + "@oxc-resolver/binding-android-arm-eabi": "11.12.0", + "@oxc-resolver/binding-android-arm64": "11.12.0", + "@oxc-resolver/binding-darwin-arm64": "11.12.0", + "@oxc-resolver/binding-darwin-x64": "11.12.0", + "@oxc-resolver/binding-freebsd-x64": "11.12.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.12.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.12.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.12.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.12.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.12.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.12.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.12.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.12.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.12.0", + "@oxc-resolver/binding-linux-x64-musl": "11.12.0", + "@oxc-resolver/binding-wasm32-wasi": "11.12.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.12.0", + "@oxc-resolver/binding-win32-ia32-msvc": "11.12.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.12.0" } }, "node_modules/p-limit": { @@ -10331,32 +10394,45 @@ } }, "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "p-limit": "^4.0.0" }, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { + "node_modules/p-locate/node_modules/p-limit": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", "dev": true, "license": "MIT", "dependencies": { - "aggregate-error": "^3.0.0" + "yocto-queue": "^1.0.0" }, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10478,9 +10554,9 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -10513,13 +10589,13 @@ } }, "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/path-is-absolute": { @@ -10628,9 +10704,9 @@ "license": "MIT" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -10645,9 +10721,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -10678,9 +10754,9 @@ } }, "node_modules/postcss": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", - "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -10707,9 +10783,9 @@ } }, "node_modules/postcss-import": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.0.tgz", - "integrity": "sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz", + "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10821,9 +10897,9 @@ } }, "node_modules/postcss-nesting": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.1.tgz", - "integrity": "sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", + "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", "dev": true, "funding": [ { @@ -10837,7 +10913,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/selector-resolve-nested": "^3.0.0", + "@csstools/selector-resolve-nested": "^3.1.0", "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" }, @@ -10870,9 +10946,9 @@ "license": "MIT" }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { @@ -10942,36 +11018,6 @@ "node": "10.* || >= 12.*" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11006,9 +11052,9 @@ } }, "node_modules/quick-temp/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -11066,26 +11112,26 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-docgen": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.0.tgz", - "integrity": "sha512-kmob/FOTwep7DUWf9KjuenKX0vyvChr3oTdvvPt09V60Iz75FJp+T/0ZeHMbAfJj2WaVWqAPP5Hmm3PYzSPPKg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.1.tgz", + "integrity": "sha512-kQKsqPLplY3Hx4jGnM3jpQcG3FQDt7ySz32uTHt3C9HAe45kNXG+3o16Eqn3Fw1GtMfHoN3b4J/z2e6cZJCmqQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.18.9", - "@babel/traverse": "^7.18.9", - "@babel/types": "^7.18.9", - "@types/babel__core": "^7.18.0", - "@types/babel__traverse": "^7.18.0", + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", @@ -11097,9 +11143,9 @@ } }, "node_modules/react-docgen-typescript": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz", - "integrity": "sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -11107,28 +11153,29 @@ } }, "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.0" + "react": "^19.2.0" } }, "node_modules/react-i18next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz", - "integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.2.4.tgz", + "integrity": "sha512-pvbcPQ+YuQQoRkKBA4VCU9aO8dOgP/vdKEizIYXcAk3+AmI8yQKSJaCzxQQu4Kgg2zWZm3ax9KqHv8ItUlRY0A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.0", - "html-parse-stringify": "^3.0.1" + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 23.2.3", + "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, @@ -11153,9 +11200,9 @@ "peer": true }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -11163,9 +11210,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", - "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -11299,6 +11346,16 @@ "node": ">= 4" } }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11389,13 +11446,6 @@ "dev": true, "license": "ISC" }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -11451,25 +11501,28 @@ } }, "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", "dev": true, - "license": "ISC" + "license": "MIT" }, "node_modules/reusify": { "version": "1.1.0", @@ -11510,15 +11563,15 @@ } }, "node_modules/rimraf/node_modules/glob": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", - "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -11534,9 +11587,9 @@ } }, "node_modules/rimraf/node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -11550,9 +11603,9 @@ } }, "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", + "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", "dev": true, "license": "ISC", "engines": { @@ -11560,13 +11613,13 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" @@ -11593,13 +11646,13 @@ } }, "node_modules/rollup": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", - "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", + "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -11609,26 +11662,27 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.1", - "@rollup/rollup-android-arm64": "4.40.1", - "@rollup/rollup-darwin-arm64": "4.40.1", - "@rollup/rollup-darwin-x64": "4.40.1", - "@rollup/rollup-freebsd-arm64": "4.40.1", - "@rollup/rollup-freebsd-x64": "4.40.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", - "@rollup/rollup-linux-arm-musleabihf": "4.40.1", - "@rollup/rollup-linux-arm64-gnu": "4.40.1", - "@rollup/rollup-linux-arm64-musl": "4.40.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", - "@rollup/rollup-linux-riscv64-gnu": "4.40.1", - "@rollup/rollup-linux-riscv64-musl": "4.40.1", - "@rollup/rollup-linux-s390x-gnu": "4.40.1", - "@rollup/rollup-linux-x64-gnu": "4.40.1", - "@rollup/rollup-linux-x64-musl": "4.40.1", - "@rollup/rollup-win32-arm64-msvc": "4.40.1", - "@rollup/rollup-win32-ia32-msvc": "4.40.1", - "@rollup/rollup-win32-x64-msvc": "4.40.1", + "@rollup/rollup-android-arm-eabi": "4.50.1", + "@rollup/rollup-android-arm64": "4.50.1", + "@rollup/rollup-darwin-arm64": "4.50.1", + "@rollup/rollup-darwin-x64": "4.50.1", + "@rollup/rollup-freebsd-arm64": "4.50.1", + "@rollup/rollup-freebsd-x64": "4.50.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", + "@rollup/rollup-linux-arm-musleabihf": "4.50.1", + "@rollup/rollup-linux-arm64-gnu": "4.50.1", + "@rollup/rollup-linux-arm64-musl": "4.50.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", + "@rollup/rollup-linux-ppc64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-musl": "4.50.1", + "@rollup/rollup-linux-s390x-gnu": "4.50.1", + "@rollup/rollup-linux-x64-gnu": "4.50.1", + "@rollup/rollup-linux-x64-musl": "4.50.1", + "@rollup/rollup-openharmony-arm64": "4.50.1", + "@rollup/rollup-win32-arm64-msvc": "4.50.1", + "@rollup/rollup-win32-ia32-msvc": "4.50.1", + "@rollup/rollup-win32-x64-msvc": "4.50.1", "fsevents": "~2.3.2" } }, @@ -11642,16 +11696,6 @@ "node": "6.* || >= 7.*" } }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11676,16 +11720,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -11715,29 +11749,19 @@ "license": "MIT" }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" - }, - "node_modules/scuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz", - "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", - "dev": true, + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/sentence-case": { @@ -11756,17 +11780,15 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.2.tgz", - "integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==", - "dev": true, + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz", + "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", "license": "MIT", "engines": { "node": ">=10" @@ -11813,9 +11835,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "dev": true, "license": "MIT", "engines": { @@ -11863,24 +11885,55 @@ } }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/smol-toml": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.4.tgz", - "integrity": "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11902,9 +11955,9 @@ } }, "node_modules/solid-js": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz", - "integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==", + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.9.tgz", + "integrity": "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==", "dev": true, "license": "MIT", "dependencies": { @@ -11930,13 +11983,13 @@ } }, "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -11974,9 +12027,9 @@ "license": "MIT" }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { @@ -11991,17 +12044,18 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.0.8.tgz", - "integrity": "sha512-GlOB3HAtzRYc237+o46nnETNkc2Qckh3UrIJ1rJyAzagIlPWau/jTxjSz76sqRODEnt01m8CyIkw3PGv0q1UpQ==", + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz", + "integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", - "@vitest/expect": "3.0.9", - "@vitest/spy": "3.0.9", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/spy": "3.2.4", "better-opn": "^3.0.2", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "esbuild-register": "^3.5.0", @@ -12025,91 +12079,17 @@ } } }, - "node_modules/storybook-i18n": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/storybook-i18n/-/storybook-i18n-4.0.5.tgz", - "integrity": "sha512-uy6k7N5VU8PRSoMo6tVYo1WNSDRd8Z3goSku7J1Cz8A8WseBN5xAnGZ/IbO5DLUOVBetLZdaKHBVoLKbYidHjQ==", + "node_modules/storybook/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/icons": "^1.4.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "peerDependencies": { - "storybook": "^9.0.0" - } - }, - "node_modules/storybook-react-i18next": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/storybook-react-i18next/-/storybook-react-i18next-4.0.4.tgz", - "integrity": "sha512-eDHqX2hSuJJyDmBWulDgE/z6aL2XiiWZsJZeIm20w4JpgHwqQBy/Q4qqTpuiCCfUu+eQt4O/zvKRIX4yI4SD4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "storybook-i18n": "^4.0.5" - }, - "peerDependencies": { - "i18next": "^22.0.0 || ^23.0.0 || ^24.0.0 || ^25.0.0", - "i18next-browser-languagedetector": "^7.0.0 || ^8.0.0", - "i18next-http-backend": "^2.0.0 || ^3.0.0", - "react-i18next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", - "storybook": "^9.0.0" - } - }, - "node_modules/storybook/node_modules/@vitest/expect": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", - "integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.0.9", - "@vitest/utils": "3.0.9", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/storybook/node_modules/@vitest/pretty-format": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", - "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/storybook/node_modules/@vitest/spy": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz", - "integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/storybook/node_modules/@vitest/utils": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz", - "integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.0.9", - "loupe": "^3.1.3", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=10" } }, "node_modules/stream-composer": { @@ -12123,9 +12103,9 @@ } }, "node_modules/streamx": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", - "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", "dev": true, "license": "MIT", "dependencies": { @@ -12229,14 +12209,11 @@ } }, "node_modules/strip-indent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", - "integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.0.tgz", + "integrity": "sha512-OA95x+JPmL7kc7zCu+e+TeYxEiaIyndRx0OrBcK2QPPH09oAndr2ALvymxWA+Lx1PYYvFUm4O63pRkdJAaW96w==", "dev": true, "license": "MIT", - "dependencies": { - "min-indent": "^1.0.1" - }, "engines": { "node": ">=12" }, @@ -12245,9 +12222,9 @@ } }, "node_modules/strip-json-comments": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz", - "integrity": "sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.2.tgz", + "integrity": "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==", "dev": true, "license": "MIT", "engines": { @@ -12337,9 +12314,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.22.0.tgz", - "integrity": "sha512-8YlCSxiyb8uPFa7qoB1lRHYr1PBbT1NuV9RvQdFFPFPudRBTPf9coU5jl02KhzvrtmTEw4jXRgb0kg8pJvVuWQ==", + "version": "5.29.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.5.tgz", + "integrity": "sha512-2zFnjONgLXlz8gLToRKvXHKJdqXF6UGgCmv65i8T6i/UrjDNyV1fIQ7FauZA40SaivlGKEvW2tw9XDyDhfcXqQ==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -12413,9 +12390,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12427,7 +12404,7 @@ "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.6", + "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", @@ -12436,7 +12413,7 @@ "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", @@ -12553,13 +12530,6 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -12641,14 +12611,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -12658,9 +12628,9 @@ } }, "node_modules/tinypool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", - "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -12678,9 +12648,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "dev": true, "license": "MIT", "engines": { @@ -12697,19 +12667,26 @@ "tslib": "^2.0.3" } }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "node_modules/tldts": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.14.tgz", + "integrity": "sha512-lMNHE4aSI3LlkMUMicTmAG3tkkitjOQGDTFboPJwAg2kJXKP1ryWEyqujktg5qhrFZOkk5YFzgkxg3jErE+i5w==", "dev": true, "license": "MIT", "dependencies": { - "os-tmpdir": "~1.0.2" + "tldts-core": "^7.0.14" }, - "engines": { - "node": ">=0.6.0" + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.14.tgz", + "integrity": "sha512-viZGNK6+NdluOJWwTO9olaugx0bkKhscIdriQQ+lNNhwitIKvb+SvhbYgnCz6j9p7dX3cJntt4agQAKMXLjJ5g==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12737,29 +12714,16 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^7.0.5" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { @@ -12815,9 +12779,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.19.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", - "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "version": "4.20.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", + "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -12834,33 +12798,10 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -12872,9 +12813,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", - "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", "dev": true, "funding": [ { @@ -12923,42 +12864,35 @@ } }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", "dev": true, "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, - "node_modules/undici/node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", - "dev": true, - "license": "ISC" - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13009,6 +12943,16 @@ "node": ">=14.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -13060,21 +13004,10 @@ "tslib": "^2.0.3" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/urlpattern-polyfill": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", - "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", "dev": true, "license": "MIT" }, @@ -13122,9 +13055,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13175,14 +13108,13 @@ } }, "node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "license": "MIT", "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -13243,14 +13175,14 @@ } }, "node_modules/vinyl-fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.0.tgz", - "integrity": "sha512-7GbgBnYfaquMk3Qu9g22x000vbYkOex32930rBnc3qByw6HfMEAoELjCjoJv4HuEQxHAurT+nvMHm6MnJllFLw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz", + "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==", "dev": true, "license": "MIT", "dependencies": { "fs-mkdirp-stream": "^2.0.1", - "glob-stream": "^8.0.0", + "glob-stream": "^8.0.3", "graceful-fs": "^4.2.11", "iconv-lite": "^0.6.3", "is-valid-glob": "^1.0.0", @@ -13261,7 +13193,7 @@ "streamx": "^2.14.0", "to-through": "^3.0.0", "value-or-function": "^4.0.0", - "vinyl": "^3.0.0", + "vinyl": "^3.0.1", "vinyl-sourcemap": "^2.0.0" }, "engines": { @@ -13287,24 +13219,24 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -13313,14 +13245,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -13362,9 +13294,9 @@ } }, "node_modules/vite-node": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.3.tgz", - "integrity": "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { @@ -13415,15 +13347,15 @@ } }, "node_modules/vite-plugin-graphql-codegen": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.6.1.tgz", - "integrity": "sha512-6uTRv8jD1pp9kt6StjOL6BGj166qVXmRwe06m9I1qtxjIVf+i7aF95gFv0NKxhEXXaDr1hFVlpp+3Ts+SQAy4g==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.7.0.tgz", + "integrity": "sha512-6TXkpUPZunV+RHP+A5R6ohar6WWjfWxTN8OpBsrZmGlJlVEpwc+2FaquAtUwO1B6kzxEomqJ7q5Idnns57hTxg==", "dev": true, "license": "MIT", "peerDependencies": { - "@graphql-codegen/cli": ">=1.0.0 <6.0.0", + "@graphql-codegen/cli": ">=1.0.0 <7.0.0", "graphql": ">=14.0.0 <17.0.0", - "vite": ">=2.7.0 <7.0.0" + "vite": ">=2.7.0 <8.0.0" } }, "node_modules/vite-plugin-manifest-sri": { @@ -13434,20 +13366,20 @@ "license": "MIT" }, "node_modules/vitest": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.3.tgz", - "integrity": "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.3", - "@vitest/mocker": "3.2.3", - "@vitest/pretty-format": "^3.2.3", - "@vitest/runner": "3.2.3", - "@vitest/snapshot": "3.2.3", - "@vitest/spy": "3.2.3", - "@vitest/utils": "3.2.3", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -13458,10 +13390,10 @@ "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", - "tinypool": "^1.1.0", + "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.3", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { @@ -13477,8 +13409,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.3", - "@vitest/ui": "3.2.3", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -13532,9 +13464,9 @@ } }, "node_modules/walk-sync/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -13556,20 +13488,13 @@ } }, "node_modules/walk-up-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", - "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", "dev": true, - "license": "ISC" - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" + "license": "ISC", + "engines": { + "node": "20 || >=22" } }, "node_modules/web-streams-polyfill": { @@ -13583,14 +13508,11 @@ } }, "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } + "license": "BSD-2-Clause" }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", @@ -13633,13 +13555,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13722,9 +13637,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", "engines": { @@ -13771,25 +13686,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -13833,9 +13741,9 @@ } }, "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "dev": true, "license": "MIT", "engines": { @@ -13846,27 +13754,14 @@ } }, "node_modules/zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-validation-error": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", - "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.18.0" - } } } } diff --git a/frontend/package.json b/frontend/package.json index 0db963fb2..38d5faa1f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,70 +18,68 @@ "knip": "knip" }, "dependencies": { - "@fontsource/inconsolata": "^5.2.6", - "@fontsource/inter": "^5.2.6", - "@radix-ui/react-collapsible": "^1.1.8", - "@radix-ui/react-dialog": "^1.1.11", - "@tanstack/react-query": "^5.80.6", - "@tanstack/react-router": "^1.121.0", - "@vector-im/compound-design-tokens": "4.0.2", - "@vector-im/compound-web": "^7.12.0", + "@fontsource/inconsolata": "^5.2.8", + "@fontsource/inter": "^5.2.8", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@tanstack/react-query": "^5.90.5", + "@tanstack/react-router": "^1.131.44", + "@vector-im/compound-design-tokens": "6.0.0", + "@vector-im/compound-web": "^8.2.4", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "i18next": "^25.2.1", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-i18next": "^15.5.2", - "swagger-ui-dist": "^5.22.0", + "i18next": "^25.6.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.2.4", + "swagger-ui-dist": "^5.29.5", "valibot": "^1.1.0", "vaul": "^1.1.2" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", + "@biomejs/biome": "^2.3.2", "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", - "@codecov/vite-plugin": "^1.9.1", - "@graphql-codegen/cli": "^5.0.7", - "@graphql-codegen/client-preset": "^4.8.0", + "@graphql-codegen/cli": "^6.0.1", + "@graphql-codegen/client-preset": "^5.1.0", "@graphql-codegen/typescript-msw": "^3.0.1", - "@storybook/addon-docs": "^9.0.8", - "@storybook/react-vite": "^9.0.8", - "@tanstack/react-query-devtools": "^5.80.6", - "@tanstack/react-router-devtools": "^1.121.0", - "@tanstack/router-plugin": "^1.121.0", - "@testing-library/jest-dom": "^6.6.3", + "@storybook/addon-docs": "^9.1.13", + "@storybook/react-vite": "^9.1.13", + "@tanstack/react-query-devtools": "^5.90.2", + "@tanstack/react-router-devtools": "^1.131.44", + "@tanstack/router-plugin": "^1.131.44", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.0.0", - "@types/react": "19.1.7", - "@types/react-dom": "19.1.6", - "@types/swagger-ui-dist": "^3.30.5", - "@vitejs/plugin-react": "^4.5.2", - "@vitest/coverage-v8": "^3.2.3", + "@types/node": "^24.9.1", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/swagger-ui-dist": "^3.30.6", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.21", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.11.0", - "happy-dom": "^17.6.3", + "happy-dom": "^20.0.4", "i18next-parser": "^9.3.0", - "knip": "^5.59.1", - "msw": "^2.8.7", + "knip": "^5.66.4", + "msw": "^2.11.6", "msw-storybook-addon": "^2.0.5", - "postcss": "^8.5.4", - "postcss-import": "^16.1.0", - "postcss-nesting": "^13.0.1", + "postcss": "^8.5.6", + "postcss-import": "^16.1.1", + "postcss-nesting": "^13.0.2", "rimraf": "^6.0.1", - "storybook": "^9.0.1", - "storybook-react-i18next": "^4.0.4", - "tailwindcss": "^3.4.17", - "typescript": "^5.8.3", - "vite": "6.3.5", + "storybook": "^9.1.5", + "tailwindcss": "^3.4.18", + "typescript": "^5.9.3", + "vite": "7.1.12", "vite-plugin-compression": "^0.5.1", - "vite-plugin-graphql-codegen": "^3.6.1", + "vite-plugin-graphql-codegen": "^3.7.0", "vite-plugin-manifest-sri": "^0.2.0", - "vitest": "^3.1.2" + "vitest": "^3.2.4" }, "msw": { "workerDirectory": [ diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 0e71a519d..99da32010 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -886,7 +886,7 @@ type Mutation { """ lockUser(input: LockUserInput!): LockUserPayload! """ - Unlock a user. This is only available to administrators. + Unlock and reactivate a user. This is only available to administrators. """ unlockUser(input: UnlockUserInput!): UnlockUserPayload! """ diff --git a/frontend/src/@types/i18next.d.ts b/frontend/src/@types/i18next.d.ts index cf32447c2..def4ce458 100644 --- a/frontend/src/@types/i18next.d.ts +++ b/frontend/src/@types/i18next.d.ts @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import "i18next"; import type translation from "../../locales/en.json"; diff --git a/frontend/src/components/AccountDeleteButton.tsx b/frontend/src/components/AccountDeleteButton.tsx index cb42edc81..6f1b80bec 100644 --- a/frontend/src/components/AccountDeleteButton.tsx +++ b/frontend/src/components/AccountDeleteButton.tsx @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { useMutation } from "@tanstack/react-query"; import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete"; @@ -70,7 +70,10 @@ const AccountDeleteButton: React.FC = (props) => { mutationFn: ({ password, hsErase, - }: { password: string | null; hsErase: boolean }) => + }: { + password: string | null; + hsErase: boolean; + }) => graphqlRequest({ query: MUTATION, variables: { password, hsErase }, diff --git a/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.module.css b/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.module.css index 51a12c9b5..b2d613a67 100644 --- a/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.module.css +++ b/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .link { diff --git a/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.tsx b/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.tsx index 6bb9ad587..c52a61ee5 100644 --- a/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.tsx +++ b/frontend/src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Link } from "@tanstack/react-router"; import { Form } from "@vector-im/compound-web"; diff --git a/frontend/src/components/AccountManagementPasswordPreview/index.ts b/frontend/src/components/AccountManagementPasswordPreview/index.ts index 8597fdffa..723421535 100644 --- a/frontend/src/components/AccountManagementPasswordPreview/index.ts +++ b/frontend/src/components/AccountManagementPasswordPreview/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./AccountManagementPasswordPreview"; diff --git a/frontend/src/components/BrowserSession.tsx b/frontend/src/components/BrowserSession.tsx index 3b2e7a58a..e4cf861c8 100644 --- a/frontend/src/components/BrowserSession.tsx +++ b/frontend/src/components/BrowserSession.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import IconChrome from "@browser-logos/chrome/chrome_64x64.png?url"; import IconFirefox from "@browser-logos/firefox/firefox_64x64.png?url"; diff --git a/frontend/src/components/ButtonLink.module.css b/frontend/src/components/ButtonLink.module.css index 70300b40d..821188520 100644 --- a/frontend/src/components/ButtonLink.module.css +++ b/frontend/src/components/ButtonLink.module.css @@ -1,7 +1,7 @@ -/* Copyright 2024 New Vector Ltd. +/* Copyright 2024, 2025 New Vector Ltd. * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ /* The weird selector is to have higher specificity than compound-web's button-link */ diff --git a/frontend/src/components/ButtonLink.tsx b/frontend/src/components/ButtonLink.tsx index 2a0a6b8b8..57362612d 100644 --- a/frontend/src/components/ButtonLink.tsx +++ b/frontend/src/components/ButtonLink.tsx @@ -1,13 +1,13 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { createLink } from "@tanstack/react-router"; import { Button } from "@vector-im/compound-web"; import cx from "classnames"; -import { type PropsWithChildren, forwardRef } from "react"; +import { forwardRef, type PropsWithChildren } from "react"; import styles from "./ButtonLink.module.css"; type Props = { diff --git a/frontend/src/components/Client/OAuth2ClientDetail.test.tsx b/frontend/src/components/Client/OAuth2ClientDetail.test.tsx index 70a9a58f8..ba3e920a0 100644 --- a/frontend/src/components/Client/OAuth2ClientDetail.test.tsx +++ b/frontend/src/components/Client/OAuth2ClientDetail.test.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @vitest-environment happy-dom diff --git a/frontend/src/components/Client/OAuth2ClientDetail.tsx b/frontend/src/components/Client/OAuth2ClientDetail.tsx index 1c8d019c2..0720da560 100644 --- a/frontend/src/components/Client/OAuth2ClientDetail.tsx +++ b/frontend/src/components/Client/OAuth2ClientDetail.tsx @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { H3 } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; diff --git a/frontend/src/components/Collapsible/Collapsible.module.css b/frontend/src/components/Collapsible/Collapsible.module.css index 5da9b4ed8..f8aec80d9 100644 --- a/frontend/src/components/Collapsible/Collapsible.module.css +++ b/frontend/src/components/Collapsible/Collapsible.module.css @@ -1,8 +1,8 @@ /* Copyright 2024, 2025 New Vector Ltd. * Copyright 2024 The Matrix.org Foundation C.I.C. * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .root { diff --git a/frontend/src/components/Collapsible/Collapsible.stories.tsx b/frontend/src/components/Collapsible/Collapsible.stories.tsx index 392b98c14..95c7a8e7e 100644 --- a/frontend/src/components/Collapsible/Collapsible.stories.tsx +++ b/frontend/src/components/Collapsible/Collapsible.stories.tsx @@ -1,7 +1,7 @@ // Copyright 2025 New Vector Ltd. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; import * as Collapsible from "./Collapsible"; diff --git a/frontend/src/components/Collapsible/Collapsible.tsx b/frontend/src/components/Collapsible/Collapsible.tsx index fb3181445..67f585c7a 100644 --- a/frontend/src/components/Collapsible/Collapsible.tsx +++ b/frontend/src/components/Collapsible/Collapsible.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import * as Collapsible from "@radix-ui/react-collapsible"; import IconChevronUp from "@vector-im/compound-design-tokens/assets/web/icons/chevron-up"; diff --git a/frontend/src/components/Collapsible/index.ts b/frontend/src/components/Collapsible/index.ts index 70e50b537..c1e0960db 100644 --- a/frontend/src/components/Collapsible/index.ts +++ b/frontend/src/components/Collapsible/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export * from "./Collapsible"; diff --git a/frontend/src/components/CompatSession.test.tsx b/frontend/src/components/CompatSession.test.tsx index 8e48de140..35430c4ce 100644 --- a/frontend/src/components/CompatSession.test.tsx +++ b/frontend/src/components/CompatSession.test.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023-2024 The Matrix.org Foundation C.I.C. +// Copyright 2024, 2025 New Vector Ltd. +// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @vitest-environment happy-dom diff --git a/frontend/src/components/CompatSession.tsx b/frontend/src/components/CompatSession.tsx index 2770993ad..c52295baf 100644 --- a/frontend/src/components/CompatSession.tsx +++ b/frontend/src/components/CompatSession.tsx @@ -1,8 +1,8 @@ // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; diff --git a/frontend/src/components/DateTime.stories.tsx b/frontend/src/components/DateTime.stories.tsx index a561cf224..4ed54f7a6 100644 --- a/frontend/src/components/DateTime.stories.tsx +++ b/frontend/src/components/DateTime.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; import { sub } from "date-fns"; diff --git a/frontend/src/components/DateTime.tsx b/frontend/src/components/DateTime.tsx index 8a901cf84..bd7e904e2 100644 --- a/frontend/src/components/DateTime.tsx +++ b/frontend/src/components/DateTime.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { differenceInHours, diff --git a/frontend/src/components/Dialog/Dialog.module.css b/frontend/src/components/Dialog/Dialog.module.css index ac59007e9..eceb0c3bc 100644 --- a/frontend/src/components/Dialog/Dialog.module.css +++ b/frontend/src/components/Dialog/Dialog.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .overlay, @@ -95,6 +95,7 @@ /* Cap the block size */ max-block-size: calc(100vh - var(--cpd-space-4x)); + /* biome-ignore lint/suspicious/noDuplicateProperties: this isn't a real duplicate */ max-block-size: calc(100svh - var(--cpd-space-4x)); /* Drawer comes in the Android style by default */ diff --git a/frontend/src/components/Dialog/Dialog.stories.tsx b/frontend/src/components/Dialog/Dialog.stories.tsx index 2059288f5..a1732a712 100644 --- a/frontend/src/components/Dialog/Dialog.stories.tsx +++ b/frontend/src/components/Dialog/Dialog.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; diff --git a/frontend/src/components/Dialog/Dialog.tsx b/frontend/src/components/Dialog/Dialog.tsx index 7f227d24e..45dba57a2 100644 --- a/frontend/src/components/Dialog/Dialog.tsx +++ b/frontend/src/components/Dialog/Dialog.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Close, @@ -102,4 +102,4 @@ export const Title: React.FC = ({ children }) => ( {children} ); -export { Description, Close } from "@radix-ui/react-dialog"; +export { Close, Description } from "@radix-ui/react-dialog"; diff --git a/frontend/src/components/Dialog/index.ts b/frontend/src/components/Dialog/index.ts index 8ce19f439..73467a18a 100644 --- a/frontend/src/components/Dialog/index.ts +++ b/frontend/src/components/Dialog/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. -export { Close, Dialog, Title, Description } from "./Dialog"; +export { Close, Description, Dialog, Title } from "./Dialog"; diff --git a/frontend/src/components/EmptyState/EmptyState.module.css b/frontend/src/components/EmptyState/EmptyState.module.css index 7a882aab1..e83a56df5 100644 --- a/frontend/src/components/EmptyState/EmptyState.module.css +++ b/frontend/src/components/EmptyState/EmptyState.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .empty-state { diff --git a/frontend/src/components/EmptyState/EmptyState.stories.tsx b/frontend/src/components/EmptyState/EmptyState.stories.tsx index b153ce212..33b32b3a5 100644 --- a/frontend/src/components/EmptyState/EmptyState.stories.tsx +++ b/frontend/src/components/EmptyState/EmptyState.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; diff --git a/frontend/src/components/EmptyState/EmptyState.tsx b/frontend/src/components/EmptyState/EmptyState.tsx index dbeae0d75..b40e3e885 100644 --- a/frontend/src/components/EmptyState/EmptyState.tsx +++ b/frontend/src/components/EmptyState/EmptyState.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import { forwardRef } from "react"; diff --git a/frontend/src/components/EmptyState/index.ts b/frontend/src/components/EmptyState/index.ts index dde4804fe..6acdbdfdd 100644 --- a/frontend/src/components/EmptyState/index.ts +++ b/frontend/src/components/EmptyState/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { EmptyState as default } from "./EmptyState"; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 3d8122a93..ad460f875 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { type ErrorInfo, PureComponent, type ReactNode } from "react"; diff --git a/frontend/src/components/ExternalLink/ExternalLink.module.css b/frontend/src/components/ExternalLink/ExternalLink.module.css index 0555143e0..2d02ce33e 100644 --- a/frontend/src/components/ExternalLink/ExternalLink.module.css +++ b/frontend/src/components/ExternalLink/ExternalLink.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .external-link { diff --git a/frontend/src/components/ExternalLink/ExternalLink.tsx b/frontend/src/components/ExternalLink/ExternalLink.tsx index 98865667e..7b75891f0 100644 --- a/frontend/src/components/ExternalLink/ExternalLink.tsx +++ b/frontend/src/components/ExternalLink/ExternalLink.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Link } from "@vector-im/compound-web"; import classNames from "classnames"; diff --git a/frontend/src/components/Filter/Filter.module.css b/frontend/src/components/Filter/Filter.module.css index e9c8561e9..6bd2e392f 100644 --- a/frontend/src/components/Filter/Filter.module.css +++ b/frontend/src/components/Filter/Filter.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .filter { diff --git a/frontend/src/components/Filter/Filter.stories.tsx b/frontend/src/components/Filter/Filter.stories.tsx index 1393020b3..a9d8fa3d8 100644 --- a/frontend/src/components/Filter/Filter.stories.tsx +++ b/frontend/src/components/Filter/Filter.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; diff --git a/frontend/src/components/Filter/Filter.tsx b/frontend/src/components/Filter/Filter.tsx index 187c75d5d..a7c10800f 100644 --- a/frontend/src/components/Filter/Filter.tsx +++ b/frontend/src/components/Filter/Filter.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { createLink } from "@tanstack/react-router"; import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; diff --git a/frontend/src/components/Filter/index.ts b/frontend/src/components/Filter/index.ts index 521f0b4b4..e544ba38b 100644 --- a/frontend/src/components/Filter/index.ts +++ b/frontend/src/components/Filter/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { Filter as default } from "./Filter"; diff --git a/frontend/src/components/Footer/Footer.module.css b/frontend/src/components/Footer/Footer.module.css index 43f80286c..65b482986 100644 --- a/frontend/src/components/Footer/Footer.module.css +++ b/frontend/src/components/Footer/Footer.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .legal-footer { diff --git a/frontend/src/components/Footer/Footer.stories.tsx b/frontend/src/components/Footer/Footer.stories.tsx index 812f9a9f2..82e01a93b 100644 --- a/frontend/src/components/Footer/Footer.stories.tsx +++ b/frontend/src/components/Footer/Footer.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 7fbbc9d2d..d6e05d2ad 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Link } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; diff --git a/frontend/src/components/Footer/index.ts b/frontend/src/components/Footer/index.ts index eb0958197..7db230d90 100644 --- a/frontend/src/components/Footer/index.ts +++ b/frontend/src/components/Footer/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./Footer"; diff --git a/frontend/src/components/GenericError.module.css b/frontend/src/components/GenericError.module.css index 7d7d417b6..e349b24c8 100644 --- a/frontend/src/components/GenericError.module.css +++ b/frontend/src/components/GenericError.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .details { diff --git a/frontend/src/components/GenericError.tsx b/frontend/src/components/GenericError.tsx index c54a5b049..c12bd70c5 100644 --- a/frontend/src/components/GenericError.tsx +++ b/frontend/src/components/GenericError.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import IconErrorSolid from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import { Button } from "@vector-im/compound-web"; diff --git a/frontend/src/components/Layout/Layout.module.css b/frontend/src/components/Layout/Layout.module.css index 04f36211e..794f16783 100644 --- a/frontend/src/components/Layout/Layout.module.css +++ b/frontend/src/components/Layout/Layout.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .layout-container { @@ -16,6 +16,7 @@ /* Fallback for browsers that do not support 100svh */ min-height: 100vh; + /* biome-ignore lint/suspicious/noDuplicateProperties: this isn't a real duplicate */ min-height: 100svh; margin: 0 auto; diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx index 207478cc7..039bee28a 100644 --- a/frontend/src/components/Layout/Layout.tsx +++ b/frontend/src/components/Layout/Layout.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import cx from "classnames"; diff --git a/frontend/src/components/Layout/index.ts b/frontend/src/components/Layout/index.ts index 303e6cf06..042bd5da2 100644 --- a/frontend/src/components/Layout/index.ts +++ b/frontend/src/components/Layout/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default, query } from "./Layout"; diff --git a/frontend/src/components/Link.tsx b/frontend/src/components/Link.tsx index d9b791ae3..8dabc17f9 100644 --- a/frontend/src/components/Link.tsx +++ b/frontend/src/components/Link.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { createLink } from "@tanstack/react-router"; import { Link as CompoundLink } from "@vector-im/compound-web"; diff --git a/frontend/src/components/LoadingScreen/LoadingScreen.module.css b/frontend/src/components/LoadingScreen/LoadingScreen.module.css index b3be023a1..5963f9ecc 100644 --- a/frontend/src/components/LoadingScreen/LoadingScreen.module.css +++ b/frontend/src/components/LoadingScreen/LoadingScreen.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .loading-screen { @@ -10,6 +10,7 @@ /* Fallback for browsers that do not support 100svh */ min-height: 100vh; + /* biome-ignore lint/suspicious/noDuplicateProperties: this isn't a real duplicate */ min-height: 100svh; justify-content: center; diff --git a/frontend/src/components/LoadingScreen/LoadingScreen.stories.tsx b/frontend/src/components/LoadingScreen/LoadingScreen.stories.tsx index d794b4c07..3c018c4ab 100644 --- a/frontend/src/components/LoadingScreen/LoadingScreen.stories.tsx +++ b/frontend/src/components/LoadingScreen/LoadingScreen.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; diff --git a/frontend/src/components/LoadingScreen/LoadingScreen.test.tsx b/frontend/src/components/LoadingScreen/LoadingScreen.test.tsx index b4fdae354..b5b3b8219 100644 --- a/frontend/src/components/LoadingScreen/LoadingScreen.test.tsx +++ b/frontend/src/components/LoadingScreen/LoadingScreen.test.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @vitest-environment happy-dom diff --git a/frontend/src/components/LoadingScreen/LoadingScreen.tsx b/frontend/src/components/LoadingScreen/LoadingScreen.tsx index 6195ce4b8..a514470ba 100644 --- a/frontend/src/components/LoadingScreen/LoadingScreen.tsx +++ b/frontend/src/components/LoadingScreen/LoadingScreen.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import LoadingSpinner from "../LoadingSpinner"; diff --git a/frontend/src/components/LoadingScreen/index.ts b/frontend/src/components/LoadingScreen/index.ts index 31f80223f..d7b64615b 100644 --- a/frontend/src/components/LoadingScreen/index.ts +++ b/frontend/src/components/LoadingScreen/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./LoadingScreen"; diff --git a/frontend/src/components/LoadingSpinner/LoadingSpinner.module.css b/frontend/src/components/LoadingSpinner/LoadingSpinner.module.css index cb15b455a..48fe727b2 100644 --- a/frontend/src/components/LoadingSpinner/LoadingSpinner.module.css +++ b/frontend/src/components/LoadingSpinner/LoadingSpinner.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ @keyframes spin { diff --git a/frontend/src/components/LoadingSpinner/LoadingSpinner.stories.tsx b/frontend/src/components/LoadingSpinner/LoadingSpinner.stories.tsx index c175984ec..33e978698 100644 --- a/frontend/src/components/LoadingSpinner/LoadingSpinner.stories.tsx +++ b/frontend/src/components/LoadingSpinner/LoadingSpinner.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; diff --git a/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx index 96a879d37..8177b87a6 100644 --- a/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import cx from "classnames"; import { useTranslation } from "react-i18next"; diff --git a/frontend/src/components/LoadingSpinner/index.ts b/frontend/src/components/LoadingSpinner/index.ts index cbc3a1932..72580588e 100644 --- a/frontend/src/components/LoadingSpinner/index.ts +++ b/frontend/src/components/LoadingSpinner/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./LoadingSpinner"; diff --git a/frontend/src/components/NavBar/NavBar.module.css b/frontend/src/components/NavBar/NavBar.module.css index dc4309efa..59a79706f 100644 --- a/frontend/src/components/NavBar/NavBar.module.css +++ b/frontend/src/components/NavBar/NavBar.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .nav-bar { diff --git a/frontend/src/components/NavBar/NavBar.stories.tsx b/frontend/src/components/NavBar/NavBar.stories.tsx index 831ea6617..194007c13 100644 --- a/frontend/src/components/NavBar/NavBar.stories.tsx +++ b/frontend/src/components/NavBar/NavBar.stories.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import type { Meta, StoryObj } from "@storybook/react-vite"; diff --git a/frontend/src/components/NavBar/NavBar.tsx b/frontend/src/components/NavBar/NavBar.tsx index 7751b509b..e35bf76a0 100644 --- a/frontend/src/components/NavBar/NavBar.tsx +++ b/frontend/src/components/NavBar/NavBar.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import styles from "./NavBar.module.css"; diff --git a/frontend/src/components/NavBar/index.ts b/frontend/src/components/NavBar/index.ts index 98b14f2c1..e8b54d06a 100644 --- a/frontend/src/components/NavBar/index.ts +++ b/frontend/src/components/NavBar/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./NavBar"; diff --git a/frontend/src/components/NavItem/NavItem.module.css b/frontend/src/components/NavItem/NavItem.module.css index 45f354ca7..a07515e16 100644 --- a/frontend/src/components/NavItem/NavItem.module.css +++ b/frontend/src/components/NavItem/NavItem.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .nav-tab { diff --git a/frontend/src/components/NavItem/NavItem.tsx b/frontend/src/components/NavItem/NavItem.tsx index 404425227..9af4d98fa 100644 --- a/frontend/src/components/NavItem/NavItem.tsx +++ b/frontend/src/components/NavItem/NavItem.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Link } from "@tanstack/react-router"; diff --git a/frontend/src/components/NavItem/index.ts b/frontend/src/components/NavItem/index.ts index 653f04a4f..91f7b6ed8 100644 --- a/frontend/src/components/NavItem/index.ts +++ b/frontend/src/components/NavItem/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./NavItem"; diff --git a/frontend/src/components/NotFound.tsx b/frontend/src/components/NotFound.tsx index e56852d78..ab92f25a5 100644 --- a/frontend/src/components/NotFound.tsx +++ b/frontend/src/components/NotFound.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Alert } from "@vector-im/compound-web"; import type { ReactNode } from "react"; diff --git a/frontend/src/components/OAuth2Session.test.tsx b/frontend/src/components/OAuth2Session.test.tsx index 9d6d840b2..575e4a2ab 100644 --- a/frontend/src/components/OAuth2Session.test.tsx +++ b/frontend/src/components/OAuth2Session.test.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. // @vitest-environment happy-dom diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx index a72fa4aba..3cac3a399 100644 --- a/frontend/src/components/OAuth2Session.tsx +++ b/frontend/src/components/OAuth2Session.tsx @@ -1,3 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../gql"; diff --git a/frontend/src/components/PageHeading/PageHeading.module.css b/frontend/src/components/PageHeading/PageHeading.module.css index df8ed4900..42e0b4774 100644 --- a/frontend/src/components/PageHeading/PageHeading.module.css +++ b/frontend/src/components/PageHeading/PageHeading.module.css @@ -1,8 +1,8 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. */ .page-heading { diff --git a/frontend/src/components/PageHeading/PageHeading.tsx b/frontend/src/components/PageHeading/PageHeading.tsx index 72efbb1e0..1d2436472 100644 --- a/frontend/src/components/PageHeading/PageHeading.tsx +++ b/frontend/src/components/PageHeading/PageHeading.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import cx from "classnames"; diff --git a/frontend/src/components/PageHeading/index.ts b/frontend/src/components/PageHeading/index.ts index 0c481b4a2..4a572d8fe 100644 --- a/frontend/src/components/PageHeading/index.ts +++ b/frontend/src/components/PageHeading/index.ts @@ -1,7 +1,7 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. export { default } from "./PageHeading"; diff --git a/frontend/src/components/PaginationControls.tsx b/frontend/src/components/PaginationControls.tsx index dc198152e..f8d3e68c3 100644 --- a/frontend/src/components/PaginationControls.tsx +++ b/frontend/src/components/PaginationControls.tsx @@ -1,8 +1,8 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. import { Button } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; @@ -39,9 +39,9 @@ const PaginationControls: React.FC = ({ {t("common.previous")}

- {count !== undefined ? ( - <>{t("frontend.pagination_controls.total", { totalCount: count })} - ) : null} + {count !== undefined + ? t("frontend.pagination_controls.total", { totalCount: count }) + : null}