diff --git a/.dockerignore b/.dockerignore index bd1816b8b..e016faae8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,6 @@ crates/*/target crates/*/node_modules frontend/node_modules frontend/dist -tools/syn2mas/** docs/ .devcontainer/ .github/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f2e112e4f..a98d07e09 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -106,20 +106,3 @@ updates: browser-logos: patterns: - "@browser-logos/*" - - - package-ecosystem: "npm" - directory: "/tools/syn2mas/" - labels: - - "A-Dependencies" - - "Z-Deps-Syn2Mas" - schedule: - interval: "weekly" - ignore: - # Ignore @types/node until we can upgrade to Node 20 - - dependency-name: "@types/node" - update-types: ["version-update:semver-major"] - groups: - production: - dependency-type: "production" - development: - dependency-type: "development" diff --git a/.github/scripts/commit-and-tag.cjs b/.github/scripts/commit-and-tag.cjs index 5a238b9ae..b95782541 100644 --- a/.github/scripts/commit-and-tag.cjs +++ b/.github/scripts/commit-and-tag.cjs @@ -13,12 +13,7 @@ module.exports = async ({ github, context }) => { const parent = context.sha; if (!version) throw new Error("VERSION is not defined"); - const files = [ - "Cargo.toml", - "Cargo.lock", - "tools/syn2mas/package.json", - "tools/syn2mas/package-lock.json", - ]; + const files = ["Cargo.toml", "Cargo.lock"]; /** @type {{path: string, mode: "100644", type: "blob", sha: string}[]} */ const tree = []; diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 17adc0012..e2bd1a304 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -23,7 +23,6 @@ env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" IMAGE: ghcr.io/element-hq/matrix-authentication-service - IMAGE_SYN2MAS: ghcr.io/element-hq/matrix-authentication-service/syn2mas BUILDCACHE: ghcr.io/element-hq/matrix-authentication-service/buildcache DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index @@ -253,24 +252,8 @@ jobs: type=semver,pattern={{major}} type=sha - - name: Docker meta (syn2mas) - id: meta-syn2mas - uses: docker/metadata-action@v5.7.0 - with: - images: "${{ env.IMAGE_SYN2MAS }}" - bake-target: docker-metadata-action-syn2mas - flavor: | - latest=auto - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - - name: Setup Cosign - uses: sigstore/cosign-installer@v3.8.1 + uses: sigstore/cosign-installer@v3.8.2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.10.0 @@ -288,13 +271,12 @@ jobs: - name: Build and push id: bake - uses: docker/bake-action@v6.5.0 + uses: docker/bake-action@v6.6.0 with: files: | ./docker-bake.hcl cwd://${{ steps.meta.outputs.bake-file }} cwd://${{ steps.meta-debug.outputs.bake-file }} - cwd://${{ steps.meta-syn2mas.outputs.bake-file }} set: | base.output=type=image,push=true base.cache-from=type=registry,ref=${{ env.BUILDCACHE }}:buildcache @@ -318,43 +300,11 @@ jobs: env: REGULAR_DIGEST: ${{ steps.output.outputs.metadata && fromJSON(steps.output.outputs.metadata).regular.digest }} DEBUG_DIGEST: ${{ steps.output.outputs.metadata && fromJSON(steps.output.outputs.metadata).debug.digest }} - SYN2MAS_DIGEST: ${{ steps.output.outputs.metadata && fromJSON(steps.output.outputs.metadata).syn2mas.digest }} run: |- cosign sign --yes \ "$IMAGE@$REGULAR_DIGEST" \ "$IMAGE@$DEBUG_DIGEST" \ - "$IMAGE_SYN2MAS@$SYN2MAS_DIGEST" - - syn2mas: - name: Release syn2mas on NPM - runs-on: ubuntu-24.04 - if: github.event_name != 'pull_request' - - permissions: - contents: read - id-token: write - - steps: - - name: Checkout the code - uses: actions/checkout@v4.2.2 - - - name: Install Node - uses: actions/setup-node@v4.4.0 - with: - node-version-file: ./tools/syn2mas/.nvmrc - - - name: Install Node dependencies - working-directory: ./tools/syn2mas - run: npm ci - - - name: Publish - uses: JS-DevTools/npm-publish@v3 - with: - package: ./tools/syn2mas - token: ${{ secrets.NPM_TOKEN }} - provenance: true - dry-run: ${{ !startsWith(github.ref, 'refs/tags/') }} release: name: Release @@ -363,7 +313,6 @@ jobs: needs: - assemble-archives - build-image - - syn2mas steps: - name: Download the artifacts from the previous job uses: actions/download-artifact@v4 @@ -403,18 +352,6 @@ jobs: ') }} ``` - `syn2mas` migration tool: - - - Digest: - ``` - ${{ env.IMAGE_SYN2MAS }}@${{ fromJSON(needs.build-image.outputs.metadata).syn2mas.digest }} - ``` - - Tags: - ``` - ${{ join(fromJSON(needs.build-image.outputs.metadata).syn2mas.tags, ' - ') }} - ``` - files: | artifacts/mas-cli-aarch64-linux.tar.gz artifacts/mas-cli-x86_64-linux.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 397de666b..b3f438f5f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -311,34 +311,6 @@ jobs: --archive-file nextest-archive.tar.zst \ --partition count:${{ matrix.partition }}/3 - syn2mas: - name: Check syn2mas - runs-on: ubuntu-24.04 - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4.2.2 - - - name: Install Node - uses: actions/setup-node@v4.4.0 - with: - node-version-file: ./tools/syn2mas/.nvmrc - - - name: Install Node dependencies - working-directory: ./tools/syn2mas - run: npm ci - - - name: Lint - working-directory: ./tools/syn2mas - run: npm run lint - - - name: Build - working-directory: ./tools/syn2mas - run: npm run build - tests-done: name: Tests done if: ${{ always() }} @@ -352,7 +324,6 @@ jobs: - clippy - check-schema - test - - syn2mas runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index b90ec3819..b6fdcd371 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -33,7 +33,7 @@ jobs: run: make coverage - name: Upload to codecov.io - uses: codecov/codecov-action@v5.4.0 + uses: codecov/codecov-action@v5.4.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: policies/coverage.json @@ -60,7 +60,7 @@ jobs: run: npm run coverage - name: Upload to codecov.io - uses: codecov/codecov-action@v5.4.0 + uses: codecov/codecov-action@v5.4.2 with: token: ${{ secrets.CODECOV_TOKEN }} directory: frontend/coverage/ @@ -127,7 +127,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.0 + uses: codecov/codecov-action@v5.4.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: target/coverage/*.lcov diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index b86579c1d..02555f5e1 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -40,10 +40,6 @@ jobs: - name: Run `cargo metadata` to make sure the lockfile is up to date run: cargo metadata --format-version 1 - - name: Set the tools/syn2mas version - working-directory: tools/syn2mas - run: npm version "${{ inputs.version }}" --no-git-tag-version - - name: Commit and tag using the GitHub API uses: actions/github-script@v7.0.1 id: commit diff --git a/.gitignore b/.gitignore index 2f7896d1d..1046cc35a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ +# Rust target/ + +# Editors +.idea +.nova + +# OS garbage +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index e4a179e53..6aa88232c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,9 +560,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", "bytes", @@ -927,9 +927,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -1004,9 +1004,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.36" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" dependencies = [ "clap_builder", "clap_derive", @@ -1014,9 +1014,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.36" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ "anstream", "anstyle", @@ -1320,9 +1320,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -1528,9 +1528,9 @@ dependencies = [ [[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", @@ -2793,14 +2793,12 @@ dependencies = [ [[package]] name = "insta" -version = "1.42.2" +version = "1.43.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084" +checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" dependencies = [ "console", - "linked-hash-map", "once_cell", - "pin-project", "serde", "similar", ] @@ -3049,12 +3047,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3114,8 +3106,9 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ + "anyhow", "axum", "axum-extra", "base64ct", @@ -3147,7 +3140,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "axum", @@ -3159,12 +3152,14 @@ dependencies = [ "dotenvy", "figment", "futures-util", + "headers", "http-body-util", "hyper", "ipnetwork", "itertools 0.14.0", "listenfd", "mas-config", + "mas-context", "mas-data-model", "mas-email", "mas-handlers", @@ -3218,7 +3213,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "camino", @@ -3246,9 +3241,25 @@ dependencies = [ "url", ] +[[package]] +name = "mas-context" +version = "0.16.0" +dependencies = [ + "console", + "opentelemetry", + "pin-project-lite", + "quanta", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "mas-data-model" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "base64ct", "chrono", @@ -3269,7 +3280,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "async-trait", "lettre", @@ -3280,7 +3291,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "aide", "anyhow", @@ -3306,6 +3317,7 @@ dependencies = [ "lettre", "mas-axum-utils", "mas-config", + "mas-context", "mas-data-model", "mas-http", "mas-i18n", @@ -3357,7 +3369,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "futures-util", "headers", @@ -3378,7 +3390,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "camino", "icu_calendar", @@ -3400,7 +3412,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "camino", "clap", @@ -3414,7 +3426,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "schemars", "serde", @@ -3422,7 +3434,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "async-trait", @@ -3438,7 +3450,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "base64ct", "chrono", @@ -3468,7 +3480,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "aead", "base64ct", @@ -3496,7 +3508,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "bytes", @@ -3504,6 +3516,7 @@ dependencies = [ "http-body", "hyper", "hyper-util", + "mas-context", "pin-project-lite", "rustls-pemfile", "socket2", @@ -3520,7 +3533,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "async-trait", @@ -3530,7 +3543,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "async-trait", @@ -3547,7 +3560,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "assert_matches", "async-trait", @@ -3583,7 +3596,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "arc-swap", @@ -3600,7 +3613,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "axum", "serde", @@ -3611,7 +3624,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "camino", "serde", @@ -3620,7 +3633,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "async-trait", "chrono", @@ -3642,7 +3655,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "async-trait", "chrono", @@ -3652,6 +3665,7 @@ dependencies = [ "mas-jose", "mas-storage", "oauth2-types", + "opentelemetry", "opentelemetry-semantic-conventions", "rand 0.8.5", "rand_chacha 0.3.1", @@ -3668,12 +3682,13 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "async-trait", "chrono", "cron", + "mas-context", "mas-data-model", "mas-email", "mas-i18n", @@ -3699,7 +3714,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "arc-swap", @@ -3729,7 +3744,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "http", "opentelemetry", @@ -3805,9 +3820,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.9.0" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98642a6dfca91122779a307b77cd07a4aa951fbe32232aaf5bad9febc66be754" +checksum = "dd72e8b4e42274540edabec853f607c015c73436159b06c39c7af85a20433155" dependencies = [ "memo-map", "self_cell", @@ -3818,9 +3833,9 @@ dependencies = [ [[package]] name = "minijinja-contrib" -version = "2.9.0" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd4a0f6e171c7bb92ed2caf446fa3de4e26561cea1d97085103e9cb42359dd59" +checksum = "457f85f9c4c5b17d11fcf9bbe7c0dbba64843c5ee040005956f1a510b6679fe2" dependencies = [ "minijinja", "serde", @@ -3999,7 +4014,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "assert_matches", "base64ct", @@ -4724,9 +4739,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.100" +version = "2.1.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70295efe3fd3db60e81f452e2eacc407b4e6c2e1ff7f763424ae6e16105cee26" +checksum = "ed067c32eda3664a59207dde92e8f895016fb375564d91626591036c741a3a89" dependencies = [ "psl-types", ] @@ -5198,9 +5213,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.26" +version = "0.23.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" +checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" dependencies = [ "aws-lc-rs", "log", @@ -5235,18 +5250,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", + "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation", "core-foundation-sys", @@ -5271,9 +5287,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437" dependencies = [ "aws-lc-rs", "ring", @@ -5376,9 +5392,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "0.32.3" +version = "0.32.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a24d8b9fcd2674a6c878a3d871f4f1380c6c43cc3718728ac96864d888458e" +checksum = "5506de3a33d9ee4ee161c5847acb87fe4f82ced6649afc9eabeb8df6f40ba94a" dependencies = [ "chrono", "inherent", @@ -5533,6 +5549,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b98005537e38ee3bc10e7d36e7febe9b8e573d03f2ddd85fcdf05d21f9abd6d" dependencies = [ + "axum", "http", "pin-project", "sentry-core", @@ -5727,9 +5744,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -6149,7 +6166,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "0.15.0-rc.0" +version = "0.16.0" dependencies = [ "anyhow", "arc-swap", @@ -6161,14 +6178,17 @@ dependencies = [ "futures-util", "insta", "mas-config", + "mas-iana", "mas-storage", "mas-storage-pg", + "oauth2-types", "opentelemetry", "opentelemetry-semantic-conventions", "rand 0.8.5", "rand_chacha 0.3.1", "rustc-hash 2.1.1", "serde", + "serde_json", "sqlx", "thiserror 2.0.12", "thiserror-ext", @@ -6176,6 +6196,7 @@ dependencies = [ "tokio-util", "tracing", "ulid", + "url", "uuid", ] @@ -6360,9 +6381,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.2" +version = "1.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" dependencies = [ "backtrace", "bytes", @@ -6435,15 +6456,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", - "hashbrown 0.14.5", + "hashbrown 0.15.2", "pin-project-lite", "tokio", ] @@ -6521,9 +6542,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +checksum = "a1cfca9ae570b2a6efc764a88e914c29b3dfaa1fafe5f495812ae97ec9bc4d53" dependencies = [ "bitflags", "bytes", diff --git a/Cargo.toml b/Cargo.toml index f2bd90196..3ae4618f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "0.15.0-rc.0" +package.version = "0.16.0" package.license = "AGPL-3.0-only" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -27,34 +27,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.15.0-rc.0" } -mas-cli = { path = "./crates/cli/", version = "=0.15.0-rc.0" } -mas-config = { path = "./crates/config/", version = "=0.15.0-rc.0" } -mas-data-model = { path = "./crates/data-model/", version = "=0.15.0-rc.0" } -mas-email = { path = "./crates/email/", version = "=0.15.0-rc.0" } -mas-graphql = { path = "./crates/graphql/", version = "=0.15.0-rc.0" } -mas-handlers = { path = "./crates/handlers/", version = "=0.15.0-rc.0" } -mas-http = { path = "./crates/http/", version = "=0.15.0-rc.0" } -mas-i18n = { path = "./crates/i18n/", version = "=0.15.0-rc.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.15.0-rc.0" } -mas-iana = { path = "./crates/iana/", version = "=0.15.0-rc.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.15.0-rc.0" } -mas-jose = { path = "./crates/jose/", version = "=0.15.0-rc.0" } -mas-keystore = { path = "./crates/keystore/", version = "=0.15.0-rc.0" } -mas-listener = { path = "./crates/listener/", version = "=0.15.0-rc.0" } -mas-matrix = { path = "./crates/matrix/", version = "=0.15.0-rc.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.15.0-rc.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.15.0-rc.0" } -mas-policy = { path = "./crates/policy/", version = "=0.15.0-rc.0" } -mas-router = { path = "./crates/router/", version = "=0.15.0-rc.0" } -mas-spa = { path = "./crates/spa/", version = "=0.15.0-rc.0" } -mas-storage = { path = "./crates/storage/", version = "=0.15.0-rc.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.15.0-rc.0" } -mas-tasks = { path = "./crates/tasks/", version = "=0.15.0-rc.0" } -mas-templates = { path = "./crates/templates/", version = "=0.15.0-rc.0" } -mas-tower = { path = "./crates/tower/", version = "=0.15.0-rc.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=0.15.0-rc.0" } -syn2mas = { path = "./crates/syn2mas", version = "=0.15.0-rc.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.16.0" } +mas-cli = { path = "./crates/cli/", version = "=0.16.0" } +mas-config = { path = "./crates/config/", version = "=0.16.0" } +mas-context = { path = "./crates/context/", version = "=0.16.0" } +mas-data-model = { path = "./crates/data-model/", version = "=0.16.0" } +mas-email = { path = "./crates/email/", version = "=0.16.0" } +mas-graphql = { path = "./crates/graphql/", version = "=0.16.0" } +mas-handlers = { path = "./crates/handlers/", version = "=0.16.0" } +mas-http = { path = "./crates/http/", version = "=0.16.0" } +mas-i18n = { path = "./crates/i18n/", version = "=0.16.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.16.0" } +mas-iana = { path = "./crates/iana/", version = "=0.16.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.16.0" } +mas-jose = { path = "./crates/jose/", version = "=0.16.0" } +mas-keystore = { path = "./crates/keystore/", version = "=0.16.0" } +mas-listener = { path = "./crates/listener/", version = "=0.16.0" } +mas-matrix = { path = "./crates/matrix/", version = "=0.16.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.16.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.16.0" } +mas-policy = { path = "./crates/policy/", version = "=0.16.0" } +mas-router = { path = "./crates/router/", version = "=0.16.0" } +mas-spa = { path = "./crates/spa/", version = "=0.16.0" } +mas-storage = { path = "./crates/storage/", version = "=0.16.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.16.0" } +mas-tasks = { path = "./crates/tasks/", version = "=0.16.0" } +mas-templates = { path = "./crates/templates/", version = "=0.16.0" } +mas-tower = { path = "./crates/tower/", version = "=0.16.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=0.16.0" } +syn2mas = { path = "./crates/syn2mas", version = "=0.16.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] @@ -84,7 +85,7 @@ version = "1.0.98" # HTTP router [workspace.dependencies.axum] -version = "0.8.3" +version = "0.8.4" # Extra utilities for Axum [workspace.dependencies.axum-extra] @@ -111,15 +112,19 @@ version = "1.1.9" [workspace.dependencies.compact_str] version = "0.9.0" +# Terminal formatting +[workspace.dependencies.console] +version = "0.15.11" + # Time utilities [workspace.dependencies.chrono] -version = "0.4.40" +version = "0.4.41" default-features = false features = ["serde", "clock"] # CLI argument parsing [workspace.dependencies.clap] -version = "4.5.36" +version = "4.5.37" features = ["derive"] # Cron expressions @@ -197,7 +202,7 @@ features = ["serde"] # Snapshot testing [workspace.dependencies.insta] -version = "1.42.2" +version = "1.43.1" features = ["yaml", "json"] # Email sending @@ -216,12 +221,12 @@ features = [ # Templates [workspace.dependencies.minijinja] -version = "2.9.0" +version = "2.10.2" features = ["loader", "json", "speedups", "unstable_machinery"] # Additional filters for minijinja [workspace.dependencies.minijinja-contrib] -version = "2.9.0" +version = "2.10.2" features = ["pycompat"] # Utilities to deal with non-zero values @@ -248,6 +253,10 @@ features = ["std"] version = "0.7.0" features = ["std"] +# Pin projection +[workspace.dependencies.pin-project-lite] +version = "0.2.16" + # PKCS#1 encoding [workspace.dependencies.pkcs1] version = "0.7.5" @@ -258,6 +267,10 @@ features = ["std"] version = "0.10.2" features = ["std", "pkcs5", "encryption"] +# High-precision clock +[workspace.dependencies.quanta] +version = "0.12.5" + # Random values [workspace.dependencies.rand] version = "0.8.5" @@ -278,11 +291,11 @@ version = "0.15.2" # TLS stack [workspace.dependencies.rustls] -version = "0.23.26" +version = "0.23.27" # Use platform-specific verifier for TLS [workspace.dependencies.rustls-platform-verifier] -version = "0.5.1" +version = "0.5.3" # JSON Schema generation [workspace.dependencies.schemars] @@ -291,12 +304,12 @@ features = ["url", "chrono", "preserve_order"] # SHA-2 cryptographic hash algorithm [workspace.dependencies.sha2] -version = "0.10.8" +version = "0.10.9" features = ["oid"] # Query builder [workspace.dependencies.sea-query] -version = "0.32.3" +version = "0.32.5" features = ["derive", "attr", "with-uuid", "with-chrono", "postgres-array"] # Query builder @@ -319,7 +332,7 @@ features = ["backtrace", "contexts", "panic", "tower", "reqwest"] # Sentry tower layer [workspace.dependencies.sentry-tower] version = "0.37.0" -features = ["http"] +features = ["http", "axum-matched-path"] # Sentry tracing integration [workspace.dependencies.sentry-tracing] @@ -358,7 +371,7 @@ version = "0.2.1" # Async runtime [workspace.dependencies.tokio] -version = "1.44.2" +version = "1.45.0" features = ["full"] [workspace.dependencies.tokio-stream] @@ -366,7 +379,7 @@ version = "0.1.17" # Useful async utilities [workspace.dependencies.tokio-util] -version = "0.7.14" +version = "0.7.15" features = ["rt"] # Tower services @@ -374,9 +387,17 @@ features = ["rt"] version = "0.5.2" features = ["util"] +# Tower service trait +[workspace.dependencies.tower-service] +version = "0.3.3" + +# Tower layer trait +[workspace.dependencies.tower-layer] +version = "0.3.3" + # Tower HTTP layers [workspace.dependencies.tower-http] -version = "0.6.2" +version = "0.6.3" features = ["cors", "fs", "add-extension", "set-header"] # Logging and tracing diff --git a/biome.json b/biome.json index af0783da6..8800caa30 100644 --- a/biome.json +++ b/biome.json @@ -21,7 +21,6 @@ "frontend/.storybook/locales.ts", "frontend/.storybook/public/mockServiceWorker.js", "frontend/locales/*.json", - "tools/syn2mas/package.json", "**/coverage/**", "**/dist/**" ] diff --git a/crates/axum-utils/Cargo.toml b/crates/axum-utils/Cargo.toml index c3fa87b6d..a112beb28 100644 --- a/crates/axum-utils/Cargo.toml +++ b/crates/axum-utils/Cargo.toml @@ -12,6 +12,7 @@ publish = false workspace = true [dependencies] +anyhow.workspace = true axum.workspace = true axum-extra.workspace = true base64ct.workspace = true diff --git a/crates/axum-utils/src/client_authorization.rs b/crates/axum-utils/src/client_authorization.rs index b3886c7b9..19d6c9e7a 100644 --- a/crates/axum-utils/src/client_authorization.rs +++ b/crates/axum-utils/src/client_authorization.rs @@ -28,6 +28,8 @@ use serde::{Deserialize, de::DeserializeOwned}; use serde_json::Value; use thiserror::Error; +use crate::record_error; + static JWT_BEARER_CLIENT_ASSERTION: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; #[derive(Deserialize)] @@ -97,7 +99,7 @@ impl Credentials { /// # Errors /// /// Returns an error if the credentials are invalid. - #[tracing::instrument(skip_all, err)] + #[tracing::instrument(skip_all)] pub async fn verify( &self, http_client: &reqwest::Client, @@ -144,7 +146,7 @@ impl Credentials { let jwks = fetch_jwks(http_client, jwks) .await - .map_err(|_| CredentialsVerificationError::JwksFetchFailed)?; + .map_err(CredentialsVerificationError::JwksFetchFailed)?; jwt.verify_with_jwks(&jwks) .map_err(|_| CredentialsVerificationError::InvalidAssertionSignature)?; @@ -214,7 +216,18 @@ pub enum CredentialsVerificationError { InvalidAssertionSignature, #[error("failed to fetch jwks")] - JwksFetchFailed, + JwksFetchFailed(#[source] Box), +} + +impl CredentialsVerificationError { + /// Returns true if the error is an internal error, not caused by the client + #[must_use] + pub fn is_internal(&self) -> bool { + matches!( + self, + Self::DecryptionError | Self::InvalidClientConfig | Self::JwksFetchFailed(_) + ) + } } #[derive(Debug, PartialEq, Eq)] @@ -231,23 +244,40 @@ impl ClientAuthorization { } } -#[derive(Debug)] +#[derive(Debug, Error)] pub enum ClientAuthorizationError { + #[error("Invalid Authorization header")] InvalidHeader, - BadForm(FailedToDeserializeForm), + + #[error("Could not deserialize request body")] + BadForm(#[source] FailedToDeserializeForm), + + #[error("client_id in form ({form:?}) does not match credential ({credential:?})")] ClientIdMismatch { credential: String, form: String }, + + #[error("Unsupported client_assertion_type: {client_assertion_type}")] UnsupportedClientAssertion { client_assertion_type: String }, + + #[error("No credentials were presented")] MissingCredentials, + + #[error("Invalid request")] InvalidRequest, + + #[error("Invalid client_assertion")] InvalidAssertion, + + #[error(transparent)] Internal(Box), } impl IntoResponse for ClientAuthorizationError { fn into_response(self) -> axum::response::Response { - match self { + let sentry_event_id = record_error!(self, Self::Internal(_)); + match &self { ClientAuthorizationError::InvalidHeader => ( StatusCode::BAD_REQUEST, + sentry_event_id, Json(ClientError::new( ClientErrorCode::InvalidRequest, "Invalid Authorization header", @@ -256,39 +286,34 @@ impl IntoResponse for ClientAuthorizationError { ClientAuthorizationError::BadForm(err) => ( StatusCode::BAD_REQUEST, + sentry_event_id, Json( ClientError::from(ClientErrorCode::InvalidRequest) .with_description(format!("{err}")), ), ), - ClientAuthorizationError::ClientIdMismatch { form, credential } => { - let description = format!( - "client_id in form ({form:?}) does not match credential ({credential:?})" - ); - - ( - StatusCode::BAD_REQUEST, - Json( - ClientError::from(ClientErrorCode::InvalidGrant) - .with_description(description), - ), - ) - } - - ClientAuthorizationError::UnsupportedClientAssertion { - client_assertion_type, - } => ( + ClientAuthorizationError::ClientIdMismatch { .. } => ( StatusCode::BAD_REQUEST, + sentry_event_id, Json( - ClientError::from(ClientErrorCode::InvalidRequest).with_description(format!( - "Unsupported client_assertion_type: {client_assertion_type}", - )), + ClientError::from(ClientErrorCode::InvalidGrant) + .with_description(format!("{self}")), + ), + ), + + ClientAuthorizationError::UnsupportedClientAssertion { .. } => ( + StatusCode::BAD_REQUEST, + sentry_event_id, + Json( + ClientError::from(ClientErrorCode::InvalidRequest) + .with_description(format!("{self}")), ), ), ClientAuthorizationError::MissingCredentials => ( StatusCode::BAD_REQUEST, + sentry_event_id, Json(ClientError::new( ClientErrorCode::InvalidRequest, "No credentials were presented", @@ -297,11 +322,13 @@ impl IntoResponse for ClientAuthorizationError { ClientAuthorizationError::InvalidRequest => ( StatusCode::BAD_REQUEST, + sentry_event_id, Json(ClientError::from(ClientErrorCode::InvalidRequest)), ), ClientAuthorizationError::InvalidAssertion => ( StatusCode::BAD_REQUEST, + sentry_event_id, Json(ClientError::new( ClientErrorCode::InvalidRequest, "Invalid client_assertion", @@ -310,6 +337,7 @@ impl IntoResponse for ClientAuthorizationError { ClientAuthorizationError::Internal(e) => ( StatusCode::INTERNAL_SERVER_ERROR, + sentry_event_id, Json( ClientError::from(ClientErrorCode::ServerError) .with_description(format!("{e}")), diff --git a/crates/axum-utils/src/error_wrapper.rs b/crates/axum-utils/src/error_wrapper.rs index 40baf520a..2bfd448fc 100644 --- a/crates/axum-utils/src/error_wrapper.rs +++ b/crates/axum-utils/src/error_wrapper.rs @@ -5,7 +5,8 @@ // Please see LICENSE in the repository root for full details. use axum::response::{IntoResponse, Response}; -use http::StatusCode; + +use crate::InternalError; /// A simple wrapper around an error that implements [`IntoResponse`]. #[derive(Debug, thiserror::Error)] @@ -14,10 +15,9 @@ pub struct ErrorWrapper(#[from] pub T); impl IntoResponse for ErrorWrapper where - T: std::error::Error, + T: std::error::Error + 'static, { fn into_response(self) -> Response { - // TODO: make this a bit more user friendly - (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response() + InternalError::from(self.0).into_response() } } diff --git a/crates/axum-utils/src/fancy_error.rs b/crates/axum-utils/src/fancy_error.rs index 50a573bee..98c2a3c51 100644 --- a/crates/axum-utils/src/fancy_error.rs +++ b/crates/axum-utils/src/fancy_error.rs @@ -15,54 +15,91 @@ use mas_templates::ErrorContext; use crate::sentry::SentryEventID; -pub struct FancyError { - context: ErrorContext, -} - -impl FancyError { - #[must_use] - pub fn new(context: ErrorContext) -> Self { - Self { context } +fn build_context(mut err: &dyn std::error::Error) -> ErrorContext { + let description = err.to_string(); + let mut details = Vec::new(); + while let Some(source) = err.source() { + err = source; + details.push(err.to_string()); } + + ErrorContext::new() + .with_description(description) + .with_details(details.join("\n")) } -impl std::fmt::Display for FancyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let code = self.context.code().unwrap_or("Internal error"); - match (self.context.description(), self.context.details()) { - (Some(description), Some(details)) => { - write!(f, "{code}: {description} ({details})") - } - (Some(message), None) | (None, Some(message)) => { - write!(f, "{code}: {message}") - } - (None, None) => { - write!(f, "{code}") - } - } - } +pub struct GenericError { + error: Box, + code: StatusCode, } -impl From for FancyError { - fn from(err: E) -> Self { - let context = ErrorContext::new() - .with_description(format!("{err}")) - .with_details(format!("{err:?}")); - FancyError { context } - } -} - -impl IntoResponse for FancyError { +impl IntoResponse for GenericError { fn into_response(self) -> Response { - let error = format!("{}", self.context); - let event_id = sentry::capture_message(&error, sentry::Level::Error); + tracing::warn!(message = &*self.error); + let context = build_context(&*self.error); + let context_text = format!("{context}"); + ( - StatusCode::INTERNAL_SERVER_ERROR, + self.code, TypedHeader(ContentType::text()), - SentryEventID::from(event_id), - Extension(self.context), - error, + Extension(context), + context_text, ) .into_response() } } + +impl GenericError { + pub fn new(code: StatusCode, err: impl std::error::Error + 'static) -> Self { + Self { + error: Box::new(err), + code, + } + } +} + +pub struct InternalError { + error: Box, +} + +impl IntoResponse for InternalError { + fn into_response(self) -> Response { + tracing::error!(message = &*self.error); + let event_id = SentryEventID::for_last_event(); + let context = build_context(&*self.error); + let context_text = format!("{context}"); + + ( + StatusCode::INTERNAL_SERVER_ERROR, + TypedHeader(ContentType::text()), + event_id, + Extension(context), + context_text, + ) + .into_response() + } +} + +impl From for InternalError { + fn from(err: E) -> Self { + Self { + error: Box::new(err), + } + } +} + +impl InternalError { + /// Create a new error from a boxed error + #[must_use] + pub fn new(error: Box) -> Self { + Self { error } + } + + /// Create a new error from an [`anyhow::Error`] + #[must_use] + pub fn from_anyhow(err: anyhow::Error) -> Self { + Self { + error: err.into_boxed_dyn_error(), + } + } +} diff --git a/crates/axum-utils/src/lib.rs b/crates/axum-utils/src/lib.rs index aa6fad9e6..a3dc31cca 100644 --- a/crates/axum-utils/src/lib.rs +++ b/crates/axum-utils/src/lib.rs @@ -22,6 +22,6 @@ pub use axum; pub use self::{ error_wrapper::ErrorWrapper, - fancy_error::FancyError, + fancy_error::{GenericError, InternalError}, session::{SessionInfo, SessionInfoExt}, }; diff --git a/crates/axum-utils/src/sentry.rs b/crates/axum-utils/src/sentry.rs index ffa8fac17..2744accff 100644 --- a/crates/axum-utils/src/sentry.rs +++ b/crates/axum-utils/src/sentry.rs @@ -13,6 +13,13 @@ use sentry::types::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SentryEventID(Uuid); +impl SentryEventID { + /// Create a new Sentry event ID header for the last event on the hub. + pub fn for_last_event() -> Option { + sentry::last_event_id().map(Self) + } +} + impl From for SentryEventID { fn from(uuid: Uuid) -> Self { Self(uuid) @@ -28,3 +35,31 @@ impl IntoResponseParts for SentryEventID { Ok(res) } } + +/// Record an error. It will emit a tracing event with the error level if +/// matches the pattern, warning otherwise. It also returns the Sentry event ID +/// if the error was recorded. +#[macro_export] +macro_rules! record_error { + ($error:expr, !) => {{ + tracing::warn!(message = &$error as &dyn std::error::Error); + Option::<$crate::sentry::SentryEventID>::None + }}; + + ($error:expr) => {{ + tracing::error!(message = &$error as &dyn std::error::Error); + + // With the `sentry-tracing` integration, Sentry should have + // captured an error, so let's extract the last event ID from the + // current hub + $crate::sentry::SentryEventID::for_last_event() + }}; + + ($error:expr, $pattern:pat) => { + if let $pattern = $error { + record_error!($error) + } else { + record_error!($error, !) + } + }; +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index cc40545cb..55def8cea 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -27,6 +27,7 @@ dialoguer = { version = "0.11.0", default-features = false, features = [ dotenvy = "0.15.7" figment.workspace = true futures-util.workspace = true +headers.workspace = true http-body-util.workspace = true hyper.workspace = true ipnetwork = "0.20.0" @@ -66,6 +67,7 @@ sentry-tracing.workspace = true sentry-tower.workspace = true mas-config.workspace = true +mas-context.workspace = true mas-data-model.workspace = true mas-email.workspace = true mas-handlers.workspace = true diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 0fb6064ea..55b592aea 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -4,10 +4,11 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use std::{convert::Infallible, net::IpAddr, sync::Arc, time::Instant}; +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_handlers::{ ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter, @@ -18,10 +19,12 @@ use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_policy::{Policy, PolicyFactory}; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng, SystemClock}; -use mas_storage_pg::PgRepository; +use mas_storage::{ + BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, RepositoryFactory, SystemClock, +}; +use mas_storage_pg::PgRepositoryFactory; use mas_templates::Templates; -use opentelemetry::{KeyValue, metrics::Histogram}; +use opentelemetry::KeyValue; use rand::SeedableRng; use sqlx::PgPool; use tracing::Instrument; @@ -30,7 +33,7 @@ use crate::telemetry::METER; #[derive(Clone)] pub struct AppState { - pub pool: PgPool, + pub repository_factory: PgRepositoryFactory, pub templates: Templates, pub key_store: Keystore, pub cookie_manager: CookieManager, @@ -46,13 +49,12 @@ pub struct AppState { pub activity_tracker: ActivityTracker, pub trusted_proxies: Vec, pub limiter: Limiter, - pub conn_acquisition_histogram: Option>, } impl AppState { /// Init the metrics for the app state. pub fn init_metrics(&mut self) { - let pool = self.pool.clone(); + let pool = self.repository_factory.pool(); METER .i64_observable_up_down_counter("db.connections.usage") .with_description("The number of connections that are currently in `state` described by the state attribute.") @@ -65,7 +67,7 @@ impl AppState { }) .build(); - let pool = self.pool.clone(); + let pool = self.repository_factory.pool(); METER .i64_observable_up_down_counter("db.connections.max") .with_description("The maximum number of open connections allowed.") @@ -75,59 +77,58 @@ impl AppState { instrument.observe(i64::from(max_conn), &[]); }) .build(); - - // Track the connection acquisition time - let histogram = METER - .u64_histogram("db.client.connections.create_time") - .with_description("The time it took to create a new connection.") - .with_unit("ms") - .build(); - self.conn_acquisition_histogram = Some(histogram); } /// Init the metadata cache in the background pub fn init_metadata_cache(&self) { - let pool = self.pool.clone(); + let factory = self.repository_factory.clone(); let metadata_cache = self.metadata_cache.clone(); let http_client = self.http_client.clone(); tokio::spawn( - async move { - let conn = match pool.acquire().await { - Ok(conn) => conn, - Err(e) => { + LogContext::new("metadata-cache-warmup") + .run(async move || { + let mut repo = match factory.create().await { + Ok(conn) => conn, + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "Failed to acquire a database connection" + ); + return; + } + }; + + if let Err(e) = metadata_cache + .warm_up_and_run( + &http_client, + std::time::Duration::from_secs(60 * 15), + &mut repo, + ) + .await + { tracing::error!( error = &e as &dyn std::error::Error, - "Failed to acquire a database connection" + "Failed to warm up the metadata cache" ); - return; } - }; - - let mut repo = PgRepository::from_conn(conn); - - if let Err(e) = metadata_cache - .warm_up_and_run( - &http_client, - std::time::Duration::from_secs(60 * 15), - &mut repo, - ) - .await - { - tracing::error!( - error = &e as &dyn std::error::Error, - "Failed to warm up the metadata cache" - ); - } - } - .instrument(tracing::info_span!("metadata_cache.background_warmup")), + }) + .instrument(tracing::info_span!("metadata_cache.background_warmup")), ); } } +// XXX(quenting): we only use this for the healthcheck endpoint, checking the db +// should be part of the repository impl FromRef for PgPool { fn from_ref(input: &AppState) -> Self { - input.pool.clone() + input.repository_factory.pool() + } +} + +impl FromRef for BoxRepositoryFactory { + fn from_ref(input: &AppState) -> Self { + input.repository_factory.clone().boxed() } } @@ -357,23 +358,13 @@ impl FromRequestParts for RequesterFingerprint { } impl FromRequestParts for BoxRepository { - type Rejection = ErrorWrapper; + type Rejection = ErrorWrapper; async fn from_request_parts( _parts: &mut axum::http::request::Parts, state: &AppState, ) -> Result { - let start = Instant::now(); - let repo = PgRepository::from_pool(&state.pool).await?; - - // Measure the time it took to create the connection - let duration = start.elapsed(); - let duration_ms = duration.as_millis().try_into().unwrap_or(u64::MAX); - - if let Some(histogram) = &state.conn_acquisition_histogram { - histogram.record(duration_ms, &[]); - } - - Ok(repo.boxed()) + let repo = state.repository_factory.create().await?; + Ok(repo) } } diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs index 0a246d86c..8416e5592 100644 --- a/crates/cli/src/commands/config.rs +++ b/crates/cli/src/commands/config.rs @@ -11,7 +11,7 @@ use camino::Utf8PathBuf; use clap::Parser; use figment::Figment; use mas_config::{ConfigurationSection, RootConfig, SyncConfig}; -use mas_storage::SystemClock; +use mas_storage::{Clock as _, SystemClock}; use mas_storage_pg::MIGRATOR; use rand::SeedableRng; use tokio::io::AsyncWriteExt; @@ -46,6 +46,10 @@ enum Subcommand { /// If not specified, the config will be written to stdout #[clap(short, long)] output: Option, + + /// Existing Synapse configuration used to generate the MAS config + #[arg(short, long, action = clap::ArgAction::Append)] + synapse_config: Vec, }, /// Sync the clients and providers from the config file to the database @@ -88,14 +92,24 @@ impl Options { info!("Configuration file looks good"); } - SC::Generate { output } => { + SC::Generate { + output, + synapse_config, + } => { let _span = info_span!("cli.config.generate").entered(); + let clock = SystemClock::default(); // XXX: we should disallow SeedableRng::from_entropy - let rng = rand_chacha::ChaChaRng::from_entropy(); - let config = RootConfig::generate(rng).await?; - let config = serde_yaml::to_string(&config)?; + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + let mut config = RootConfig::generate(&mut rng).await?; + 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)?; + config = synapse_config.adjust_mas_config(config, &mut rng, clock.now()); + } + + let config = serde_yaml::to_string(&config)?; if let Some(output) = output { info!("Writing configuration to {output:?}"); let mut file = tokio::fs::File::create(output).await?; @@ -129,7 +143,8 @@ impl Options { prune, dry_run, ) - .await?; + .await + .context("could not sync the configuration with the database")?; } } diff --git a/crates/cli/src/commands/debug.rs b/crates/cli/src/commands/debug.rs index a82d8f059..2c004974f 100644 --- a/crates/cli/src/commands/debug.rs +++ b/crates/cli/src/commands/debug.rs @@ -11,6 +11,7 @@ use figment::Figment; use mas_config::{ ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PolicyConfig, }; +use mas_storage_pg::PgRepositoryFactory; use tracing::{info, info_span}; use crate::util::{ @@ -48,7 +49,8 @@ impl Options { if with_dynamic_data { let database_config = DatabaseConfig::extract(figment)?; let pool = database_pool_from_config(&database_config).await?; - load_policy_factory_dynamic_data(&policy_factory, &pool).await?; + let repository_factory = PgRepositoryFactory::new(pool.clone()); + load_policy_factory_dynamic_data(&policy_factory, &repository_factory).await?; } let _instance = policy_factory.instantiate().await?; diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 4cf59a483..390897ce7 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -67,7 +67,7 @@ enum Subcommand { /// Add an email address to the specified user AddEmail { username: String, email: String }, - /// [DEPRECATED] Mark email address as verified + /// (DEPRECATED) Mark email address as verified VerifyEmail { username: String, email: String }, /// Set a user password @@ -255,7 +255,13 @@ impl Options { }; repo.into_inner().commit().await?; - info!(?email, "Email added"); + info!( + %user.id, + %user.username, + %email.id, + %email.email, + "Email added" + ); Ok(ExitCode::SUCCESS) } @@ -299,7 +305,7 @@ impl Options { let compat_session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, admin) + .add(&mut rng, &clock, &user, device, None, admin, None) .await?; let token = TokenType::CompatAccessToken.generate(&mut rng); diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 15074c684..4f2fc6205 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -13,11 +13,12 @@ use itertools::Itertools; use mas_config::{ AppConfig, ClientsConfig, ConfigurationSection, ConfigurationSectionExt, UpstreamOAuth2Config, }; +use mas_context::LogContext; 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; +use mas_storage_pg::{MIGRATOR, PgRepositoryFactory}; use sqlx::migrate::Migrate; use tracing::{Instrument, info, info_span, warn}; @@ -112,7 +113,8 @@ impl Options { false, false, ) - .await?; + .await + .context("could not sync the configuration with the database")?; } // Initialize the key store @@ -132,7 +134,7 @@ impl Options { load_policy_factory_dynamic_data_continuously( &policy_factory, - &pool, + PgRepositoryFactory::new(pool.clone()).boxed(), shutdown.soft_shutdown_token(), shutdown.task_tracker(), ) @@ -170,7 +172,7 @@ impl Options { info!("Starting task worker"); mas_tasks::init( - &pool, + PgRepositoryFactory::new(pool.clone()), &mailer, homeserver_connection.clone(), url_builder.clone(), @@ -191,7 +193,7 @@ impl Options { // Initialize the activity tracker // Activity is flushed every minute let activity_tracker = ActivityTracker::new( - pool.clone(), + PgRepositoryFactory::new(pool.clone()).boxed(), Duration::from_secs(60), shutdown.task_tracker(), shutdown.soft_shutdown_token(), @@ -213,7 +215,7 @@ impl Options { limiter.start(); let graphql_schema = mas_handlers::graphql_schema( - &pool, + PgRepositoryFactory::new(pool.clone()).boxed(), &policy_factory, homeserver_connection.clone(), site_config.clone(), @@ -224,7 +226,7 @@ impl Options { let state = { let mut s = AppState { - pool, + repository_factory: PgRepositoryFactory::new(pool), templates, key_store, cookie_manager, @@ -240,7 +242,6 @@ impl Options { activity_tracker, trusted_proxies, limiter, - conn_acquisition_histogram: None, }; s.init_metrics(); s.init_metadata_cache(); @@ -316,11 +317,13 @@ impl Options { shutdown .task_tracker() - .spawn(mas_listener::server::run_servers( - servers, - shutdown.soft_shutdown_token(), - shutdown.hard_shutdown_token(), - )); + .spawn(LogContext::new("run-servers").run(|| { + mas_listener::server::run_servers( + servers, + shutdown.soft_shutdown_token(), + shutdown.hard_shutdown_token(), + ) + })); let exit_code = shutdown.run().await; diff --git a/crates/cli/src/commands/syn2mas.rs b/crates/cli/src/commands/syn2mas.rs index 473afed54..ac009f5cf 100644 --- a/crates/cli/src/commands/syn2mas.rs +++ b/crates/cli/src/commands/syn2mas.rs @@ -1,3 +1,8 @@ +// Copyright 2024, 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + use std::{collections::HashMap, process::ExitCode, time::Duration}; use anyhow::Context; @@ -15,7 +20,7 @@ use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types:: use syn2mas::{ LockedMasDatabase, MasWriter, Progress, ProgressStage, SynapseReader, synapse_config, }; -use tracing::{Instrument, error, info, info_span, warn}; +use tracing::{Instrument, error, info, info_span}; use crate::util::{DatabaseConnectOptions, database_connection_from_config_with_options}; @@ -32,19 +37,10 @@ pub(super) struct Options { #[command(subcommand)] subcommand: Subcommand, - /// This version of the syn2mas tool is EXPERIMENTAL and INCOMPLETE. It is - /// only suitable for TESTING. If you want to use this tool anyway, - /// please pass this argument. - /// - /// If you want to migrate from Synapse to MAS today, please use the - /// Node.js-based tool in the MAS repository. - #[clap(long = "i-swear-i-am-just-testing-in-a-staging-environment")] - experimental_accepted: bool, - /// Path to the Synapse configuration (in YAML format). /// May be specified multiple times if multiple Synapse configuration files /// are in use. - #[clap(long = "synapse-config")] + #[clap(long = "synapse-config", global = true)] synapse_configuration_files: Vec, /// Override the Synapse database URI. @@ -64,7 +60,7 @@ pub(super) struct Options { /// environment variables `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`, /// `PGPASSWORD`, etc. It is valid to specify the URL `postgresql:` and /// configure all values through those environment variables. - #[clap(long = "synapse-database-uri")] + #[clap(long = "synapse-database-uri", global = true)] synapse_database_uri: Option, } @@ -74,8 +70,17 @@ enum Subcommand { /// /// It is OK for Synapse to be online during these checks. Check, + /// Perform a migration. Synapse must be offline during this process. - Migrate, + Migrate { + /// Perform a dry-run migration, which is safe to run with Synapse + /// running, and will restore the MAS database to an empty state. + /// + /// This still *does* write to the MAS database, making it more + /// realistic compared to the final migration. + #[clap(long)] + dry_run: bool, + }, } /// The number of parallel writing transactions active against the MAS database. @@ -85,14 +90,6 @@ impl Options { #[tracing::instrument("cli.syn2mas.run", skip_all)] #[allow(clippy::too_many_lines)] pub async fn run(self, figment: &Figment) -> anyhow::Result { - warn!( - "This version of the syn2mas tool is EXPERIMENTAL and INCOMPLETE. Do not use it, except for TESTING." - ); - if !self.experimental_accepted { - error!("Please agree that you can only use this tool for testing."); - return Ok(ExitCode::FAILURE); - } - if self.synapse_configuration_files.is_empty() { error!("Please specify the path to the Synapse configuration file(s)."); return Ok(ExitCode::FAILURE); @@ -108,7 +105,7 @@ impl Options { synapse_config .database .to_sqlx_postgres() - .context("Synapse configuration does not use Postgres, cannot migrate.")? + .context("Synapse database configuration is invalid, cannot migrate.")? }; let mut syn_conn = PgConnection::connect_with(&syn_connection_options) .await @@ -130,11 +127,10 @@ impl Options { .await .context("could not run migrations")?; - if matches!(&self.subcommand, Subcommand::Migrate) { + if matches!(&self.subcommand, Subcommand::Migrate { .. }) { // 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 clock = SystemClock::default(); let encrypter = config.secrets.encrypter(); @@ -150,7 +146,8 @@ impl Options { // Not a dry run — we do want to create the providers in the database false, ) - .await?; + .await + .context("could not sync the configuration with the database")?; } let Either::Left(mut mas_connection) = LockedMasDatabase::try_new(mas_connection) @@ -213,7 +210,8 @@ impl Options { Ok(ExitCode::SUCCESS) } - Subcommand::Migrate => { + + Subcommand::Migrate { dry_run } => { let provider_id_mappings: HashMap = { let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment)?; @@ -229,21 +227,20 @@ impl Options { // TODO how should we handle warnings at this stage? - // TODO this dry-run flag should be set to false in real circumstances !!! - let reader = SynapseReader::new(&mut syn_conn, true).await?; - let mut writer_mas_connections = Vec::with_capacity(NUM_WRITER_CONNECTIONS); - for _ in 0..NUM_WRITER_CONNECTIONS { - writer_mas_connections.push( + let reader = SynapseReader::new(&mut syn_conn, dry_run).await?; + let writer_mas_connections = + futures_util::future::try_join_all((0..NUM_WRITER_CONNECTIONS).map(|_| { database_connection_from_config_with_options( &config, &DatabaseConnectOptions { log_slow_statements: false, }, ) - .await?, - ); - } - let writer = MasWriter::new(mas_connection, writer_mas_connections).await?; + })) + .instrument(tracing::info_span!("syn2mas.mas_writer_connections")) + .await?; + let writer = + MasWriter::new(mas_connection, writer_mas_connections, dry_run).await?; let clock = SystemClock::default(); // TODO is this rng ok? @@ -256,7 +253,6 @@ impl Options { tokio::spawn(occasional_progress_logger(progress.clone())); let mas_matrix = MatrixConfig::extract(figment)?; - eprintln!("\n\n"); syn2mas::migrate( reader, writer, @@ -276,13 +272,13 @@ impl Options { } } -/// Logs progress every 30 seconds, as a lightweight alternative to a progress -/// bar. For most deployments, the migration will not take 30 seconds so this +/// Logs progress every 5 seconds, as a lightweight alternative to a progress +/// bar. For most deployments, the migration will not take 5 seconds so this /// will not be relevant. In other cases, this will give the operator an idea of /// what's going on. async fn occasional_progress_logger(progress: Progress) { loop { - tokio::time::sleep(Duration::from_secs(30)).await; + tokio::time::sleep(Duration::from_secs(5)).await; match &**progress.get_current_stage() { ProgressStage::SettingUp => { info!(name: "progress", "still setting up"); diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index da16e848a..f13a1ae3c 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -10,6 +10,7 @@ use clap::Parser; use figment::Figment; use mas_config::{AppConfig, ConfigurationSection}; use mas_router::UrlBuilder; +use mas_storage_pg::PgRepositoryFactory; use tracing::{info, info_span}; use crate::{ @@ -63,7 +64,7 @@ impl Options { info!("Starting task scheduler"); mas_tasks::init( - &pool, + PgRepositoryFactory::new(pool.clone()), &mailer, conn, url_builder, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 85c5a89f1..f0da47c09 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,10 +10,13 @@ use std::{io::IsTerminal, process::ExitCode, sync::Arc}; use anyhow::Context; use clap::Parser; -use mas_config::{ConfigurationSection, TelemetryConfig}; +use mas_config::{ConfigurationSectionExt, TelemetryConfig}; use sentry_tracing::EventFilter; use tracing_subscriber::{ - EnvFilter, Layer, Registry, filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt, + EnvFilter, Layer, Registry, + filter::{LevelFilter, filter_fn}, + layer::SubscriberExt, + util::SubscriberInitExt, }; mod app_state; @@ -91,13 +94,16 @@ async fn try_main() -> anyhow::Result { let (log_writer, _guard) = tracing_appender::non_blocking(output); let fmt_layer = tracing_subscriber::fmt::layer() .with_writer(log_writer) - .with_file(true) - .with_line_number(true) + .event_format(mas_context::EventFormatter) .with_ansi(with_ansi); let filter_layer = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new("info")) .context("could not setup logging filter")?; + // Suppress the following warning from the Jaeger propagator: + // Invalid jaeger header format header_value="" + let suppress_layer = filter_fn(|metadata| metadata.name() != "JaegerPropagator.InvalidHeader"); + // Setup the rustls crypto provider rustls::crypto::aws_lc_rs::default_provider() .install_default() @@ -110,7 +116,7 @@ async fn try_main() -> anyhow::Result { let figment = opts.figment(); let telemetry_config = - TelemetryConfig::extract(&figment).context("Failed to load telemetry config")?; + TelemetryConfig::extract_or_default(&figment).context("Failed to load telemetry config")?; // Setup Sentry let sentry = sentry::init(( @@ -129,9 +135,11 @@ async fn try_main() -> anyhow::Result { let sentry_layer = sentry.is_enabled().then(|| { sentry_tracing::layer().event_filter(|md| { - // All the spans in the handlers module send their data to Sentry themselves, so - // we only create breadcrumbs for them, instead of full events - if md.target().starts_with("mas_handlers::") { + // By default, Sentry records all events as breadcrumbs, except errors. + // + // Because we're emitting error events for 5xx responses, we need to exclude + // them and also record them as breadcrumbs. + if md.name() == "http.server.response" { EventFilter::Breadcrumb } else { sentry_tracing::default_event_filter(md) @@ -150,6 +158,7 @@ async fn try_main() -> anyhow::Result { }); let subscriber = Registry::default() + .with(suppress_layer) .with(sentry_layer) .with(telemetry_layer) .with(filter_layer) diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index b40cc0d44..2841c8471 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -16,12 +16,14 @@ use axum::{ error_handling::HandleErrorLayer, extract::{FromRef, MatchedPath}, }; +use headers::{HeaderMapExt as _, UserAgent}; use hyper::{ Method, Request, Response, StatusCode, Version, header::{CACHE_CONTROL, HeaderValue, USER_AGENT}, }; use listenfd::ListenFd; use mas_config::{HttpBindConfig, HttpResource, HttpTlsConfig, UnixOrTcp}; +use mas_context::LogContext; use mas_listener::{ConnectionInfo, unix_or_tcp::UnixOrTcpListener}; use mas_router::Route; use mas_templates::Templates; @@ -170,6 +172,43 @@ fn on_http_response_labels(res: &Response) -> Vec { )] } +async fn log_response_middleware( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let user_agent: Option = request.headers().typed_get(); + let user_agent = user_agent.as_ref().map_or("-", |u| u.as_str()); + let method = otel_http_method(&request); + let path = request.uri().path().to_owned(); + let version = otel_net_protocol_version(&request); + + let response = next.run(request).await; + + let Some(stats) = LogContext::maybe_with(LogContext::stats) else { + tracing::error!("Missing log context for request, this is a bug!"); + return response; + }; + + let status_code = response.status(); + match status_code.as_u16() { + 100..=399 => tracing::info!( + name: "http.server.response", + "\"{method} {path} HTTP/{version}\" {status_code} {user_agent:?} [{stats}]", + ), + 400..=499 => tracing::warn!( + name: "http.server.response", + "\"{method} {path} HTTP/{version}\" {status_code} {user_agent:?} [{stats}]", + ), + 500..=599 => tracing::error!( + name: "http.server.response", + "\"{method} {path} HTTP/{version}\" {status_code} {user_agent:?} [{stats}]", + ), + _ => { /* This shouldn't happen */ } + } + + response +} + pub fn build_router( state: AppState, resources: &[HttpResource], @@ -252,6 +291,7 @@ pub fn build_router( router = router.fallback(mas_handlers::fallback); router + .layer(axum::middleware::from_fn(log_response_middleware)) .layer( InFlightCounterLayer::new("http.server.active_requests").on_request(( name.map(|name| KeyValue::new(MAS_LISTENER_NAME, name.to_owned())), @@ -277,7 +317,15 @@ pub fn build_router( span.record("otel.status_code", "OK"); }), ) - .layer(SentryHttpLayer::new()) + .layer(mas_context::LogContextLayer::new(|req| { + otel_http_method(req).into() + })) + // Careful about the order here: the `NewSentryLayer` must be around the + // `SentryHttpLayer`. axum makes new layers wrap the existing ones, + // 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(NewSentryLayer::new_from_top()) .with_state(state) } diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 36f9568e8..0c1063607 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -62,7 +62,7 @@ fn map_claims_imports( } } -#[tracing::instrument(name = "config.sync", skip_all, err(Debug))] +#[tracing::instrument(name = "config.sync", skip_all)] pub async fn config_sync( upstream_oauth2_config: UpstreamOAuth2Config, clients_config: ClientsConfig, @@ -175,11 +175,11 @@ pub async fn config_sync( let _span = info_span!("provider", %provider.id).entered(); if existing_enabled_ids.contains(&provider.id) { - info!("Updating provider"); + info!(provider.id = %provider.id, "Updating provider"); } else if existing_disabled.contains_key(&provider.id) { - info!("Enabling and updating provider"); + info!(provider.id = %provider.id, "Enabling and updating provider"); } else { - info!("Adding provider"); + info!(provider.id = %provider.id, "Adding provider"); } if dry_run { @@ -252,15 +252,15 @@ pub async fn config_sync( if discovery_mode.is_disabled() { if provider.authorization_endpoint.is_none() { - error!("Provider has discovery disabled but no authorization endpoint set"); + error!(provider.id = %provider.id, "Provider has discovery disabled but no authorization endpoint set"); } if provider.token_endpoint.is_none() { - error!("Provider has discovery disabled but no token endpoint set"); + error!(provider.id = %provider.id, "Provider has discovery disabled but no token endpoint set"); } if provider.jwks_uri.is_none() { - warn!("Provider has discovery disabled but no JWKS URI set"); + warn!(provider.id = %provider.id, "Provider has discovery disabled but no JWKS URI set"); } } @@ -304,6 +304,7 @@ pub async fn config_sync( .additional_authorization_parameters .into_iter() .collect(), + forward_login_hint: provider.forward_login_hint, ui_order, }, ) @@ -347,9 +348,9 @@ pub async fn config_sync( for client in clients_config { let _span = info_span!("client", client.id = %client.client_id).entered(); if existing_ids.contains(&client.client_id) { - info!("Updating client"); + info!(client.id = %client.client_id, "Updating client"); } else { - info!("Adding client"); + info!(client.id = %client.client_id, "Adding client"); } if dry_run { @@ -357,6 +358,7 @@ pub async fn config_sync( } let client_secret = client.client_secret.as_deref(); + let client_name = client.client_name.as_ref(); let client_auth_method = client.client_auth_method(); let jwks = client.jwks.as_ref(); let jwks_uri = client.jwks_uri.as_ref(); @@ -369,6 +371,7 @@ pub async fn config_sync( repo.oauth2_client() .upsert_static( client.client_id, + client_name.cloned(), client_auth_method, encrypted_client_secret, jwks.cloned(), diff --git a/crates/cli/src/telemetry.rs b/crates/cli/src/telemetry.rs index 7d4e4e30a..a09207e44 100644 --- a/crates/cli/src/telemetry.rs +++ b/crates/cli/src/telemetry.rs @@ -119,10 +119,14 @@ fn otlp_tracer_provider( let batch_processor = BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build(); + // 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) .with_resource(resource()) - .with_sampler(Sampler::TraceIdRatioBased(sample_rate)) + .with_sampler(sampler) .build(); Ok(tracer_provider) diff --git a/crates/cli/src/telemetry/tokio.rs b/crates/cli/src/telemetry/tokio.rs index a8c0ace13..49c7ac0ef 100644 --- a/crates/cli/src/telemetry/tokio.rs +++ b/crates/cli/src/telemetry/tokio.rs @@ -42,23 +42,6 @@ pub fn observe(metrics: RuntimeMetrics) { .build(); } - { - let metrics = metrics.clone(); - METER - .u64_observable_gauge("tokio_runtime.global_queue_depth") - .with_description( - "The number of tasks currently scheduled in the runtime’s global queue", - ) - .with_unit("{task}") - .with_callback(move |instrument| { - instrument.observe( - metrics.global_queue_depth().try_into().unwrap_or(u64::MAX), - &[], - ); - }) - .build(); - } - #[cfg(tokio_unstable)] { let metrics = metrics.clone(); @@ -143,7 +126,6 @@ pub fn observe(metrics: RuntimeMetrics) { .build(); } - #[cfg(tokio_unstable)] { let metrics = metrics.clone(); METER @@ -161,6 +143,19 @@ pub fn observe(metrics: RuntimeMetrics) { .build(); } + #[cfg(tokio_unstable)] + { + let metrics = metrics.clone(); + METER + .u64_observable_counter("tokio_runtime.spawned_tasks_count") + .with_description("The number of tasks spawned in this runtime since it was created.") + .with_unit("{task}") + .with_callback(move |instrument| { + instrument.observe(metrics.spawned_tasks_count(), &[]); + }) + .build(); + } + #[cfg(tokio_unstable)] { let metrics = metrics.clone(); @@ -180,7 +175,6 @@ pub fn observe(metrics: RuntimeMetrics) { .build(); } - #[cfg(tokio_unstable)] { let metrics = metrics.clone(); METER @@ -264,7 +258,6 @@ pub fn observe(metrics: RuntimeMetrics) { .build(); } - #[cfg(tokio_unstable)] { let metrics = metrics.clone(); METER @@ -433,7 +426,6 @@ pub fn observe(metrics: RuntimeMetrics) { } /// Helper to construct a [`KeyValue`] with the worker index. -#[allow(dead_code)] fn worker_idx(i: usize) -> KeyValue { KeyValue::new("worker_idx", i.try_into().unwrap_or(i64::MAX)) } diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 041b3f67c..b2ddee763 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -12,6 +12,7 @@ use mas_config::{ EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, PasswordsConfig, PolicyConfig, TemplatesConfig, }; +use mas_context::LogContext; use mas_data_model::{SessionExpirationConfig, SiteConfig}; use mas_email::{MailTransport, Mailer}; use mas_handlers::passwords::PasswordManager; @@ -19,9 +20,8 @@ use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection}; use mas_matrix_synapse::SynapseConnection; use mas_policy::PolicyFactory; use mas_router::UrlBuilder; -use mas_storage::RepositoryAccess; -use mas_storage_pg::PgRepository; -use mas_templates::{SiteConfigExt, TemplateLoadingError, Templates}; +use mas_storage::{BoxRepositoryFactory, RepositoryAccess, RepositoryFactory}; +use mas_templates::{SiteConfigExt, Templates}; use sqlx::{ ConnectOptions, Executor, PgConnection, PgPool, postgres::{PgConnectOptions, PgPoolOptions}, @@ -109,20 +109,23 @@ pub fn test_mailer_in_background(mailer: &Mailer, timeout: Duration) { let mailer = mailer.clone(); let span = tracing::info_span!("cli.test_mailer"); - tokio::spawn(async move { - match tokio::time::timeout(timeout, mailer.test_connection()).await { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!( - error = &err as &dyn std::error::Error, - "Could not connect to the mail backend, tasks sending mails may fail!" - ); + tokio::spawn( + LogContext::new("mailer-test").run(async move || { + match tokio::time::timeout(timeout, mailer.test_connection()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + error = &err as &dyn std::error::Error, + "Could not connect to the mail backend, tasks sending mails may fail!" + ); + } + Err(_) => { + tracing::warn!("Timed out while testing the mail backend connection, tasks sending mails may fail!"); + } } - Err(_) => { - tracing::warn!("Timed out while testing the mail backend connection, tasks sending mails may fail!"); - } - } - }.instrument(span)); + }) + .instrument(span) + ); } pub async fn policy_factory_from_config( @@ -223,7 +226,7 @@ pub async fn templates_from_config( config: &TemplatesConfig, site_config: &SiteConfig, url_builder: &UrlBuilder, -) -> Result { +) -> Result { Templates::load( config.path.clone(), url_builder.clone(), @@ -233,6 +236,7 @@ pub async fn templates_from_config( site_config.templates_features(), ) .await + .with_context(|| format!("Failed to load the templates at {}", config.path)) } fn database_connect_options_from_config( @@ -332,7 +336,7 @@ fn database_connect_options_from_config( } /// Create a database connection pool from the configuration -#[tracing::instrument(name = "db.connect", skip_all, err(Debug))] +#[tracing::instrument(name = "db.connect", skip_all)] pub async fn database_pool_from_config(config: &DatabaseConfig) -> Result { let options = database_connect_options_from_config(config, &DatabaseConnectOptions::default())?; PgPoolOptions::new() @@ -368,7 +372,7 @@ impl Default for DatabaseConnectOptions { } /// Create a single database connection from the configuration -#[tracing::instrument(name = "db.connect", skip_all, err(Debug))] +#[tracing::instrument(name = "db.connect", skip_all)] pub async fn database_connection_from_config( config: &DatabaseConfig, ) -> Result { @@ -380,7 +384,7 @@ pub async fn database_connection_from_config( /// Create a single database connection from the configuration, /// with specific options. -#[tracing::instrument(name = "db.connect", skip_all, err(Debug))] +#[tracing::instrument(name = "db.connect", skip_all)] pub async fn database_connection_from_config_with_options( config: &DatabaseConfig, options: &DatabaseConnectOptions, @@ -396,14 +400,13 @@ pub async fn database_connection_from_config_with_options( // XXX: this could be put somewhere else? pub async fn load_policy_factory_dynamic_data_continuously( policy_factory: &Arc, - pool: &PgPool, + repository_factory: BoxRepositoryFactory, cancellation_token: CancellationToken, task_tracker: &TaskTracker, ) -> Result<(), anyhow::Error> { let policy_factory = policy_factory.clone(); - let pool = pool.clone(); - load_policy_factory_dynamic_data(&policy_factory, &pool).await?; + load_policy_factory_dynamic_data(&policy_factory, &*repository_factory).await?; task_tracker.spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); @@ -416,7 +419,9 @@ pub async fn load_policy_factory_dynamic_data_continuously( _ = interval.tick() => {} } - if let Err(err) = load_policy_factory_dynamic_data(&policy_factory, &pool).await { + if let Err(err) = + load_policy_factory_dynamic_data(&policy_factory, &*repository_factory).await + { tracing::error!( error = ?err, "Failed to load policy factory dynamic data" @@ -431,12 +436,13 @@ pub async fn load_policy_factory_dynamic_data_continuously( } /// Update the policy factory dynamic data from the database -#[tracing::instrument(name = "policy.load_dynamic_data", skip_all, err(Debug))] +#[tracing::instrument(name = "policy.load_dynamic_data", skip_all)] pub async fn load_policy_factory_dynamic_data( policy_factory: &PolicyFactory, - pool: &PgPool, + repository_factory: &(dyn RepositoryFactory + Send + Sync), ) -> Result<(), anyhow::Error> { - let mut repo = PgRepository::from_pool(pool) + let mut repo = repository_factory + .create() .await .context("Failed to acquire database connection")?; diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 0dc767132..7566b59c0 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -30,7 +30,7 @@ serde_with = { version = "3.12.0", features = ["hex", "chrono"] } serde_json.workspace = true pem-rfc7468 = "0.7.0" -rustls-pki-types = "1.11.0" +rustls-pki-types = "1.12.0" rustls-pemfile = "2.2.0" rand.workspace = true rand_chacha.workspace = true diff --git a/crates/config/src/sections/clients.rs b/crates/config/src/sections/clients.rs index 84aa55a22..2a0469677 100644 --- a/crates/config/src/sections/clients.rs +++ b/crates/config/src/sections/clients.rs @@ -79,6 +79,10 @@ pub struct ClientConfig { /// Authentication method used for this client client_auth_method: ClientAuthMethodConfig, + /// Name of the `OAuth2` client + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, + /// 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")] diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index d415f646a..9a9fc9de8 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -38,7 +38,9 @@ pub use self::{ Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp, }, matrix::{HomeserverKind, MatrixConfig}, - passwords::{Algorithm as PasswordAlgorithm, PasswordsConfig}, + passwords::{ + Algorithm as PasswordAlgorithm, HashingScheme as PasswordHashingScheme, PasswordsConfig, + }, policy::PolicyConfig, rate_limiting::RateLimitingConfig, secrets::SecretsConfig, diff --git a/crates/config/src/sections/passwords.rs b/crates/config/src/sections/passwords.rs index 455dbfd61..07ea71b0e 100644 --- a/crates/config/src/sections/passwords.rs +++ b/crates/config/src/sections/passwords.rs @@ -16,7 +16,7 @@ use crate::ConfigurationSection; fn default_schemes() -> Vec { vec![HashingScheme { version: 1, - algorithm: Algorithm::Argon2id, + algorithm: Algorithm::default(), cost: None, secret: None, secret_file: None, @@ -36,10 +36,14 @@ fn default_minimum_complexity() -> u8 { pub struct PasswordsConfig { /// Whether password-based authentication is enabled #[serde(default = "default_enabled")] - enabled: bool, + pub enabled: bool, + /// The hashing schemes to use for hashing and validating passwords + /// + /// The hashing scheme with the highest version number will be used for + /// hashing new passwords. #[serde(default = "default_schemes")] - schemes: Vec, + pub schemes: Vec, /// Score between 0 and 4 determining the minimum allowed password /// complexity. Scores are based on the ESTIMATED number of guesses @@ -154,23 +158,30 @@ impl PasswordsConfig { } } +/// Parameters for a password hashing scheme #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct HashingScheme { - version: u16, + /// The version of the hashing scheme. They must be unique, and the highest + /// version will be used for hashing new passwords. + pub version: u16, - algorithm: Algorithm, + /// The hashing algorithm to use + pub algorithm: Algorithm, /// Cost for the bcrypt algorithm #[serde(skip_serializing_if = "Option::is_none")] #[schemars(default = "default_bcrypt_cost")] - cost: Option, + pub cost: Option, + /// An optional secret to use when hashing passwords. This makes it harder + /// to brute-force the passwords in case of a database leak. #[serde(skip_serializing_if = "Option::is_none")] - secret: Option, + pub secret: Option, + /// Same as `secret`, but read from a file. #[serde(skip_serializing_if = "Option::is_none")] #[schemars(with = "Option")] - secret_file: Option, + pub secret_file: Option, } #[allow(clippy::unnecessary_wraps)] @@ -179,13 +190,14 @@ fn default_bcrypt_cost() -> Option { } /// A hashing algorithm -#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum Algorithm { /// bcrypt Bcrypt, /// argon2id + #[default] Argon2id, /// PBKDF2 diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 6a375586b..10df52e02 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -70,7 +70,7 @@ impl SecretsConfig { /// # Errors /// /// Returns an error when a key could not be imported - #[tracing::instrument(name = "secrets.load", skip_all, err(Debug))] + #[tracing::instrument(name = "secrets.load", skip_all)] pub async fn key_store(&self) -> anyhow::Result { let mut keys = Vec::with_capacity(self.keys.len()); for item in &self.keys { diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 623a97c14..aa6a27254 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -400,6 +400,14 @@ pub struct SignInWithApple { pub key_id: String, } +fn default_scope() -> String { + "openid".to_owned() +} + +fn is_default_scope(scope: &str) -> bool { + scope == default_scope() +} + /// Configuration for one upstream OAuth 2 provider. #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -418,6 +426,23 @@ pub struct Provider { )] pub id: Ulid, + /// The ID of the provider that was used by Synapse. + /// In order to perform a Synapse-to-MAS migration, this must be specified. + /// + /// ## For providers that used OAuth 2.0 or OpenID Connect in Synapse + /// + /// ### For `oidc_providers`: + /// This should be specified as `oidc-` followed by the ID that was + /// configured as `idp_id` in one of the `oidc_providers` in the Synapse + /// configuration. + /// For example, if Synapse's configuration contained `idp_id: wombat` for + /// this provider, then specify `oidc-wombat` here. + /// + /// ### For `oidc_config` (legacy): + /// Specify `oidc` here. + #[serde(skip_serializing_if = "Option::is_none")] + pub synapse_idp_id: Option, + /// The OIDC issuer URL /// /// This is required if OIDC discovery is enabled (which is the default) @@ -478,6 +503,9 @@ pub struct Provider { pub id_token_signed_response_alg: JsonWebSignatureAlg, /// The scopes to request from the provider + /// + /// Defaults to `openid`. + #[serde(default = "default_scope", skip_serializing_if = "is_default_scope")] pub scope: String, /// How to discover the provider's configuration @@ -549,20 +577,10 @@ pub struct Provider { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub additional_authorization_parameters: BTreeMap, - /// The ID of the provider that was used by Synapse. - /// In order to perform a Synapse-to-MAS migration, this must be specified. + /// Whether the `login_hint` should be forwarded to the provider in the + /// authorization request. /// - /// ## For providers that used OAuth 2.0 or OpenID Connect in Synapse - /// - /// ### For `oidc_providers`: - /// This should be specified as `oidc-` followed by the ID that was - /// configured as `idp_id` in one of the `oidc_providers` in the Synapse - /// configuration. - /// For example, if Synapse's configuration contained `idp_id: wombat` for - /// this provider, then specify `oidc-wombat` here. - /// - /// ### For `oidc_config` (legacy): - /// Specify `oidc` here. - #[serde(skip_serializing_if = "Option::is_none")] - pub synapse_idp_id: Option, + /// Defaults to `false`. + #[serde(default)] + pub forward_login_hint: bool, } diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml new file mode 100644 index 000000000..762985080 --- /dev/null +++ b/crates/context/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mas-context" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +console.workspace = true +pin-project-lite.workspace = true +quanta.workspace = true +tokio.workspace = true +tower-service.workspace = true +tower-layer.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +tracing-opentelemetry.workspace = true +opentelemetry.workspace = true diff --git a/crates/context/src/fmt.rs b/crates/context/src/fmt.rs new file mode 100644 index 000000000..b074a9c34 --- /dev/null +++ b/crates/context/src/fmt.rs @@ -0,0 +1,163 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use console::{Color, Style}; +use opentelemetry::{ + TraceId, + trace::{SamplingDecision, TraceContextExt}, +}; +use tracing::{Level, Subscriber}; +use tracing_opentelemetry::OtelData; +use tracing_subscriber::{ + fmt::{ + FormatEvent, FormatFields, + format::{DefaultFields, Writer}, + time::{FormatTime, SystemTime}, + }, + registry::LookupSpan, +}; + +use crate::LogContext; + +/// An event formatter usable by the [`tracing-subscriber`] crate, which +/// includes the log context and the OTEL trace ID. +#[derive(Debug, Default)] +pub struct EventFormatter; + +struct FmtLevel<'a> { + level: &'a Level, + ansi: bool, +} + +impl<'a> FmtLevel<'a> { + pub(crate) fn new(level: &'a Level, ansi: bool) -> Self { + Self { level, ansi } + } +} + +const TRACE_STR: &str = "TRACE"; +const DEBUG_STR: &str = "DEBUG"; +const INFO_STR: &str = " INFO"; +const WARN_STR: &str = " WARN"; +const ERROR_STR: &str = "ERROR"; + +const TRACE_STYLE: Style = Style::new().fg(Color::Magenta); +const DEBUG_STYLE: Style = Style::new().fg(Color::Blue); +const INFO_STYLE: Style = Style::new().fg(Color::Green); +const WARN_STYLE: Style = Style::new().fg(Color::Yellow); +const ERROR_STYLE: Style = Style::new().fg(Color::Red); + +impl std::fmt::Display for FmtLevel<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match *self.level { + Level::TRACE => TRACE_STYLE.force_styling(self.ansi).apply_to(TRACE_STR), + Level::DEBUG => DEBUG_STYLE.force_styling(self.ansi).apply_to(DEBUG_STR), + Level::INFO => INFO_STYLE.force_styling(self.ansi).apply_to(INFO_STR), + Level::WARN => WARN_STYLE.force_styling(self.ansi).apply_to(WARN_STR), + Level::ERROR => ERROR_STYLE.force_styling(self.ansi).apply_to(ERROR_STR), + }; + write!(f, "{msg}") + } +} + +struct TargetFmt<'a> { + target: &'a str, + line: Option, +} + +impl<'a> TargetFmt<'a> { + pub(crate) fn new(metadata: &tracing::Metadata<'a>) -> Self { + Self { + target: metadata.target(), + line: metadata.line(), + } + } +} + +impl std::fmt::Display for TargetFmt<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.target)?; + if let Some(line) = self.line { + write!(f, ":{line}")?; + } + Ok(()) + } +} + +impl FormatEvent for EventFormatter +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'writer> FormatFields<'writer> + 'static, +{ + fn format_event( + &self, + ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &tracing::Event<'_>, + ) -> std::fmt::Result { + let ansi = writer.has_ansi_escapes(); + let metadata = event.metadata(); + + SystemTime.format_time(&mut writer)?; + + let level = FmtLevel::new(metadata.level(), ansi); + write!(&mut writer, " {level} ")?; + + // If there is no explicit 'name' set in the event macro, it will have the + // 'event {filename}:{line}' value. In this case, we want to display the target: + // the module from where it was emitted. In other cases, we want to + // display the explit name of the event we have set. + let style = Style::new().dim().force_styling(ansi); + if metadata.name().starts_with("event ") { + write!(&mut writer, "{} ", style.apply_to(TargetFmt::new(metadata)))?; + } else { + write!(&mut writer, "{} ", style.apply_to(metadata.name()))?; + } + + LogContext::maybe_with(|log_context| { + let log_context = Style::new() + .bold() + .force_styling(ansi) + .apply_to(log_context); + write!(&mut writer, "{log_context} - ") + }) + .transpose()?; + + let field_fromatter = DefaultFields::new(); + 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}")?; + } + } + } + } + + writeln!(&mut writer) + } +} diff --git a/crates/context/src/future.rs b/crates/context/src/future.rs new file mode 100644 index 000000000..9e93af4fa --- /dev/null +++ b/crates/context/src/future.rs @@ -0,0 +1,59 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::{ + pin::Pin, + sync::atomic::Ordering, + task::{Context, Poll}, +}; + +use quanta::Instant; +use tokio::task::futures::TaskLocalFuture; + +use crate::LogContext; + +pub type LogContextFuture = TaskLocalFuture>; + +impl LogContext { + /// Wrap a future with the given log context + pub(crate) fn wrap_future(&self, future: F) -> LogContextFuture { + let future = PollRecordingFuture::new(future); + crate::CURRENT_LOG_CONTEXT.scope(self.clone(), future) + } +} + +pin_project_lite::pin_project! { + /// A future which records the elapsed time and the number of polls in the + /// active log context + pub struct PollRecordingFuture { + #[pin] + inner: F, + } +} + +impl PollRecordingFuture { + pub(crate) fn new(inner: F) -> Self { + Self { inner } + } +} + +impl Future for PollRecordingFuture { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let start = Instant::now(); + let this = self.project(); + let result = this.inner.poll(cx); + + // Record the number of polls and the time we spent polling the future + let elapsed = start.elapsed().as_nanos().try_into().unwrap_or(u64::MAX); + let _ = crate::CURRENT_LOG_CONTEXT.try_with(|c| { + c.inner.polls.fetch_add(1, Ordering::Relaxed); + c.inner.cpu_time.fetch_add(elapsed, Ordering::Relaxed); + }); + + result + } +} diff --git a/crates/context/src/layer.rs b/crates/context/src/layer.rs new file mode 100644 index 000000000..0ce6e3497 --- /dev/null +++ b/crates/context/src/layer.rs @@ -0,0 +1,41 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::borrow::Cow; + +use tower_layer::Layer; +use tower_service::Service; + +use crate::LogContextService; + +/// A layer which creates a log context for each request. +pub struct LogContextLayer { + tagger: fn(&R) -> Cow<'static, str>, +} + +impl Clone for LogContextLayer { + fn clone(&self) -> Self { + Self { + tagger: self.tagger, + } + } +} + +impl LogContextLayer { + pub fn new(tagger: fn(&R) -> Cow<'static, str>) -> Self { + Self { tagger } + } +} + +impl Layer for LogContextLayer +where + S: Service, +{ + type Service = LogContextService; + + fn layer(&self, inner: S) -> Self::Service { + LogContextService::new(inner, self.tagger) + } +} diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs new file mode 100644 index 000000000..655d407e9 --- /dev/null +++ b/crates/context/src/lib.rs @@ -0,0 +1,152 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod fmt; +mod future; +mod layer; +mod service; + +use std::{ + borrow::Cow, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use quanta::Instant; +use tokio::task_local; + +pub use self::{ + fmt::EventFormatter, + future::{LogContextFuture, PollRecordingFuture}, + layer::LogContextLayer, + service::LogContextService, +}; + +/// A counter which increments each time we create a new log context +/// It will wrap around if we create more than [`u64::MAX`] contexts +static LOG_CONTEXT_INDEX: AtomicU64 = AtomicU64::new(0); +task_local! { + pub static CURRENT_LOG_CONTEXT: LogContext; +} + +/// A log context saves informations about the current task, such as the +/// elapsed time, the number of polls, and the poll time. +#[derive(Clone)] +pub struct LogContext { + inner: Arc, +} + +struct LogContextInner { + /// A user-defined tag for the log context + tag: Cow<'static, str>, + + /// A unique index for the log context + index: u64, + + /// The time when the context was created + start: Instant, + + /// The number of [`Future::poll`] recorded + polls: AtomicU64, + + /// An approximation of the total CPU time spent in the context, in + /// nanoseconds + cpu_time: AtomicU64, +} + +impl LogContext { + /// Create a new log context with the given tag + pub fn new(tag: impl Into>) -> Self { + let tag = tag.into(); + let inner = LogContextInner { + tag, + index: LOG_CONTEXT_INDEX.fetch_add(1, Ordering::Relaxed), + start: Instant::now(), + polls: AtomicU64::new(0), + cpu_time: AtomicU64::new(0), + }; + + Self { + inner: Arc::new(inner), + } + } + + /// Run a closure with the current log context, if any + pub fn maybe_with(f: F) -> Option + where + F: FnOnce(&Self) -> R, + { + CURRENT_LOG_CONTEXT.try_with(f).ok() + } + + /// Run the async function `f` with the given log context. It will wrap the + /// output future to record poll and CPU statistics. + pub fn run Fut, Fut: Future>(&self, f: F) -> LogContextFuture { + let future = self.run_sync(f); + self.wrap_future(future) + } + + /// Run the sync function `f` with the given log context, recording the CPU + /// time spent. + pub fn run_sync R, R>(&self, f: F) -> R { + let start = Instant::now(); + let result = CURRENT_LOG_CONTEXT.sync_scope(self.clone(), f); + let elapsed = start.elapsed().as_nanos().try_into().unwrap_or(u64::MAX); + self.inner.cpu_time.fetch_add(elapsed, Ordering::Relaxed); + result + } + + /// Create a snapshot of the log context statistics + #[must_use] + pub fn stats(&self) -> LogContextStats { + let polls = self.inner.polls.load(Ordering::Relaxed); + let cpu_time = self.inner.cpu_time.load(Ordering::Relaxed); + let cpu_time = Duration::from_nanos(cpu_time); + let elapsed = self.inner.start.elapsed(); + LogContextStats { + polls, + cpu_time, + elapsed, + } + } +} + +impl std::fmt::Display for LogContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let tag = &self.inner.tag; + let index = self.inner.index; + write!(f, "{tag}-{index}") + } +} + +/// A snapshot of a log context statistics +#[derive(Debug, Clone, Copy)] +pub struct LogContextStats { + /// How many times the context was polled + pub polls: u64, + + /// The approximate CPU time spent in the context + pub cpu_time: Duration, + + /// How much time elapsed since the context was created + pub elapsed: Duration, +} + +impl std::fmt::Display for LogContextStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let polls = self.polls; + #[expect(clippy::cast_precision_loss)] + let cpu_time_ms = self.cpu_time.as_nanos() as f64 / 1_000_000.; + #[expect(clippy::cast_precision_loss)] + let elapsed_ms = self.elapsed.as_nanos() as f64 / 1_000_000.; + write!( + f, + "polls: {polls}, cpu: {cpu_time_ms:.1}ms, elapsed: {elapsed_ms:.1}ms", + ) + } +} diff --git a/crates/context/src/service.rs b/crates/context/src/service.rs new file mode 100644 index 000000000..98a1d1184 --- /dev/null +++ b/crates/context/src/service.rs @@ -0,0 +1,54 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::{ + borrow::Cow, + task::{Context, Poll}, +}; + +use tower_service::Service; + +use crate::{LogContext, LogContextFuture}; + +/// A service which wraps another service and creates a log context for +/// each request. +pub struct LogContextService { + inner: S, + tagger: fn(&R) -> Cow<'static, str>, +} + +impl Clone for LogContextService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + tagger: self.tagger, + } + } +} + +impl LogContextService { + pub fn new(inner: S, tagger: fn(&R) -> Cow<'static, str>) -> Self { + Self { inner, tagger } + } +} + +impl Service for LogContextService +where + S: Service, +{ + type Response = S::Response; + type Error = S::Error; + type Future = LogContextFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: R) -> Self::Future { + let tag = (self.tagger)(&req); + let log_context = LogContext::new(tag); + log_context.run(|| self.inner.call(req)) + } +} diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml index a76865cd5..6f0e20d0c 100644 --- a/crates/data-model/Cargo.toml +++ b/crates/data-model/Cargo.toml @@ -18,7 +18,7 @@ thiserror.workspace = true serde.workspace = true serde_json.workspace = true url.workspace = true -crc = "3.2.1" +crc = "3.3.0" ulid.workspace = true rand.workspace = true regex = "1.11.1" diff --git a/crates/data-model/src/compat/session.rs b/crates/data-model/src/compat/session.rs index fc660c3f0..91b48cea0 100644 --- a/crates/data-model/src/compat/session.rs +++ b/crates/data-model/src/compat/session.rs @@ -11,7 +11,7 @@ use serde::Serialize; use ulid::Ulid; use super::Device; -use crate::{InvalidTransitionError, UserAgent}; +use crate::InvalidTransitionError; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub enum CompatSessionState { @@ -76,7 +76,7 @@ pub struct CompatSession { pub user_session_id: Option, pub created_at: DateTime, pub is_synapse_admin: bool, - pub user_agent: Option, + pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, } diff --git a/crates/data-model/src/oauth2/authorization_grant.rs b/crates/data-model/src/oauth2/authorization_grant.rs index 170e476d0..1d71f0170 100644 --- a/crates/data-model/src/oauth2/authorization_grant.rs +++ b/crates/data-model/src/oauth2/authorization_grant.rs @@ -160,6 +160,7 @@ pub struct AuthorizationGrant { pub response_type_id_token: bool, pub created_at: DateTime, pub login_hint: Option, + pub locale: Option, } impl std::ops::Deref for AuthorizationGrant { @@ -263,6 +264,7 @@ impl AuthorizationGrant { response_type_id_token: false, created_at: now, login_hint: Some(String::from("mxid:@example-user:example.com")), + locale: Some(String::from("fr")), } } } diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs index bf230b850..794cc460b 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -11,7 +11,7 @@ use oauth2_types::scope::Scope; use serde::Serialize; use ulid::Ulid; -use crate::{BrowserSession, InvalidTransitionError, Session, UserAgent}; +use crate::{BrowserSession, InvalidTransitionError, Session}; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case", tag = "state")] @@ -192,7 +192,7 @@ pub struct DeviceCodeGrant { pub ip_address: Option, /// The user agent used to request this device code grant. - pub user_agent: Option, + pub user_agent: Option, } impl std::ops::Deref for DeviceCodeGrant { diff --git a/crates/data-model/src/oauth2/session.rs b/crates/data-model/src/oauth2/session.rs index 675701619..8a55aa863 100644 --- a/crates/data-model/src/oauth2/session.rs +++ b/crates/data-model/src/oauth2/session.rs @@ -11,7 +11,7 @@ use oauth2_types::scope::Scope; use serde::Serialize; use ulid::Ulid; -use crate::{InvalidTransitionError, UserAgent}; +use crate::InvalidTransitionError; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub enum SessionState { @@ -80,9 +80,10 @@ pub struct Session { pub user_session_id: Option, pub client_id: Ulid, pub scope: Scope, - pub user_agent: Option, + pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, + pub human_name: Option, } impl std::ops::Deref for Session { diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index b81704661..7362d807b 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -241,6 +241,7 @@ pub struct UpstreamOAuthProvider { pub disabled_at: Option>, pub claims_imports: ClaimsImports, pub additional_authorization_parameters: Vec<(String, String)>, + pub forward_login_hint: bool, } impl PartialOrd for UpstreamOAuthProvider { diff --git a/crates/data-model/src/upstream_oauth2/session.rs b/crates/data-model/src/upstream_oauth2/session.rs index ef3623cfc..c5b45234c 100644 --- a/crates/data-model/src/upstream_oauth2/session.rs +++ b/crates/data-model/src/upstream_oauth2/session.rs @@ -250,7 +250,7 @@ pub struct UpstreamOAuthAuthorizationSession { pub provider_id: Ulid, pub state_str: String, pub code_challenge_verifier: Option, - pub nonce: String, + pub nonce: Option, pub created_at: DateTime, } diff --git a/crates/data-model/src/user_agent.rs b/crates/data-model/src/user_agent.rs index 2b02ebd48..2ac4b06bd 100644 --- a/crates/data-model/src/user_agent.rs +++ b/crates/data-model/src/user_agent.rs @@ -4,9 +4,18 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +use std::sync::LazyLock; + use serde::Serialize; use woothee::{parser::Parser, woothee::VALUE_UNKNOWN}; +static CUSTOM_USER_AGENT_REGEX: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^(?P[^/]+)/(?P[^ ]+) \((?P.+)\)$").unwrap() +}); + +static ELECTRON_USER_AGENT_REGEX: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(?m)\w+/[\w.]+").unwrap()); + #[derive(Debug, Serialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DeviceType { @@ -37,10 +46,7 @@ impl std::ops::Deref for UserAgent { impl UserAgent { fn parse_custom(user_agent: &str) -> Option<(&str, &str, &str, &str, Option<&str>)> { - let regex = regex::Regex::new(r"^(?P[^/]+)/(?P[^ ]+) \((?P.+)\)$") - .unwrap(); - - let captures = regex.captures(user_agent)?; + let captures = CUSTOM_USER_AGENT_REGEX.captures(user_agent)?; let name = captures.name("name")?.as_str(); let version = captures.name("version")?.as_str(); let segments: Vec<&str> = captures @@ -73,9 +79,8 @@ impl UserAgent { } fn parse_electron(user_agent: &str) -> Option<(&str, &str)> { - let regex = regex::Regex::new(r"(?m)\w+/[\w.]+").unwrap(); let omit_keys = ["Mozilla", "AppleWebKit", "Chrome", "Electron", "Safari"]; - return regex + return ELECTRON_USER_AGENT_REGEX .find_iter(user_agent) .map(|caps| caps.as_str().split_once('/').unwrap()) .find(|pair| !omit_keys.contains(&pair.0)); diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 41b6c4f70..7e40f4df2 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -12,8 +12,6 @@ use serde::Serialize; use ulid::Ulid; use url::Url; -use crate::UserAgent; - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct User { pub id: Ulid, @@ -81,7 +79,7 @@ pub enum AuthenticationMethod { pub struct UserRecoverySession { pub id: Ulid, pub email: String, - pub user_agent: UserAgent, + pub user_agent: String, pub ip_address: Option, pub locale: String, pub created_at: DateTime, @@ -137,7 +135,7 @@ pub struct BrowserSession { pub user: User, pub created_at: DateTime, pub finished_at: Option>, - pub user_agent: Option, + pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, } @@ -159,9 +157,9 @@ impl BrowserSession { user, created_at: now, finished_at: None, - user_agent: Some(UserAgent::parse( + 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() - )), + ), last_active_at: Some(now), last_active_ip: None, }) @@ -213,7 +211,7 @@ pub struct UserRegistration { pub password: Option, pub post_auth_action: Option, pub ip_address: Option, - pub user_agent: Option, + pub user_agent: Option, pub created_at: DateTime, pub completed_at: Option>, } diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index ed0968269..443859a3b 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -111,7 +111,6 @@ impl Mailer { email.to = %to, email.language = %context.language(), ), - err, )] pub async fn send_verification_email( &self, @@ -137,7 +136,6 @@ impl Mailer { user.id = %context.user().id, user_recovery_session.id = %context.session().id, ), - err, )] pub async fn send_recovery_email( &self, @@ -154,7 +152,7 @@ impl Mailer { /// # Errors /// /// Returns an error if the connection failed - #[tracing::instrument(name = "email.test_connection", skip_all, err)] + #[tracing::instrument(name = "email.test_connection", skip_all)] pub async fn test_connection(&self) -> Result<(), crate::transport::Error> { self.transport.test_connection().await } diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 65c7bbb6f..4d8477003 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -76,7 +76,7 @@ hex.workspace = true governor.workspace = true indexmap.workspace = true pkcs8.workspace = true -psl = "2.1.100" +psl = "2.1.106" sha2.workspace = true time = "0.3.41" url.workspace = true @@ -90,6 +90,7 @@ ulid.workspace = true mas-axum-utils.workspace = true mas-config.workspace = true +mas-context.workspace = true mas-data-model.workspace = true mas-http.workspace = true mas-i18n.workspace = true diff --git a/crates/handlers/src/activity_tracker/mod.rs b/crates/handlers/src/activity_tracker/mod.rs index 232c636ef..56785e236 100644 --- a/crates/handlers/src/activity_tracker/mod.rs +++ b/crates/handlers/src/activity_tracker/mod.rs @@ -11,8 +11,7 @@ use std::net::IpAddr; use chrono::{DateTime, Utc}; use mas_data_model::{BrowserSession, CompatSession, Session}; -use mas_storage::Clock; -use sqlx::PgPool; +use mas_storage::{BoxRepositoryFactory, Clock}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use ulid::Ulid; @@ -61,12 +60,12 @@ impl ActivityTracker { /// time, when the cancellation token is cancelled. #[must_use] pub fn new( - pool: PgPool, + repository_factory: BoxRepositoryFactory, flush_interval: std::time::Duration, task_tracker: &TaskTracker, cancellation_token: CancellationToken, ) -> Self { - let worker = Worker::new(pool); + let worker = Worker::new(repository_factory); let (sender, receiver) = tokio::sync::mpsc::channel(MESSAGE_QUEUE_SIZE); let tracker = ActivityTracker { channel: sender }; @@ -185,6 +184,8 @@ impl ActivityTracker { // This guard on the shutdown token is to ensure that if this task crashes for // any reason, the server will shut down let _guard = cancellation_token.clone().drop_guard(); + let mut interval = tokio::time::interval(interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { @@ -202,7 +203,7 @@ impl ActivityTracker { } - () = tokio::time::sleep(interval) => { + _ = interval.tick() => { self.flush().await; } } diff --git a/crates/handlers/src/activity_tracker/worker.rs b/crates/handlers/src/activity_tracker/worker.rs index 949ab690d..4787964ee 100644 --- a/crates/handlers/src/activity_tracker/worker.rs +++ b/crates/handlers/src/activity_tracker/worker.rs @@ -7,12 +7,13 @@ use std::{collections::HashMap, net::IpAddr}; use chrono::{DateTime, Utc}; -use mas_storage::{RepositoryAccess, user::BrowserSessionRepository}; +use mas_storage::{ + BoxRepositoryFactory, RepositoryAccess, RepositoryError, user::BrowserSessionRepository, +}; use opentelemetry::{ Key, KeyValue, - metrics::{Counter, Histogram}, + metrics::{Counter, Gauge, Histogram}, }; -use sqlx::PgPool; use tokio_util::sync::CancellationToken; use ulid::Ulid; @@ -25,8 +26,8 @@ use crate::{ /// database automatically. /// /// The [`ActivityRecord`] structure plus the key in the [`HashMap`] takes less -/// than 100 bytes, so this should allocate around a megabyte of memory. -static MAX_PENDING_RECORDS: usize = 10_000; +/// than 100 bytes, so this should allocate around 100kB of memory. +static MAX_PENDING_RECORDS: usize = 1000; const TYPE: Key = Key::from_static_str("type"); const SESSION_KIND: Key = Key::from_static_str("session_kind"); @@ -43,14 +44,15 @@ struct ActivityRecord { /// Handles writing activity records to the database. pub struct Worker { - pool: PgPool, + repository_factory: BoxRepositoryFactory, pending_records: HashMap<(SessionKind, Ulid), ActivityRecord>, + pending_records_gauge: Gauge, message_counter: Counter, flush_time_histogram: Histogram, } impl Worker { - pub(crate) fn new(pool: PgPool) -> Self { + pub(crate) fn new(repository_factory: BoxRepositoryFactory) -> Self { let message_counter = METER .u64_counter("mas.activity_tracker.messages") .with_description("The number of messages received by the activity tracker") @@ -80,9 +82,17 @@ impl Worker { .with_unit("ms") .build(); + let pending_records_gauge = METER + .u64_gauge("mas.activity_tracker.pending_records") + .with_description("The number of pending activity records") + .with_unit("{records}") + .build(); + pending_records_gauge.record(0, &[]); + Self { - pool, + repository_factory, pending_records: HashMap::with_capacity(MAX_PENDING_RECORDS), + pending_records_gauge, message_counter, flush_time_histogram, } @@ -165,6 +175,10 @@ impl Worker { let _ = tx.send(()); } } + + // Update the gauge + self.pending_records_gauge + .record(self.pending_records.len() as u64, &[]); } // Flush one last time @@ -193,19 +207,19 @@ impl Worker { Err(e) => { self.flush_time_histogram .record(duration_ms, &[KeyValue::new(RESULT, "failure")]); - tracing::error!("Failed to flush activity tracker: {}", e); + tracing::error!( + error = &e as &dyn std::error::Error, + "Failed to flush activity tracker" + ); } } } /// Fallible part of [`Self::flush`]. #[tracing::instrument(name = "activity_tracker.flush", skip(self))] - async fn try_flush(&mut self) -> Result<(), anyhow::Error> { + async fn try_flush(&mut self) -> Result<(), RepositoryError> { let pending_records = &self.pending_records; - - let mut repo = mas_storage_pg::PgRepository::from_pool(&self.pool) - .await? - .boxed(); + let mut repo = self.repository_factory.create().await?; let mut browser_sessions = Vec::new(); let mut oauth2_sessions = Vec::new(); diff --git a/crates/handlers/src/admin/call_context.rs b/crates/handlers/src/admin/call_context.rs index 0b812db05..95340b160 100644 --- a/crates/handlers/src/admin/call_context.rs +++ b/crates/handlers/src/admin/call_context.rs @@ -15,6 +15,7 @@ use axum::{ 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 ulid::Ulid; @@ -69,27 +70,35 @@ pub enum Rejection { MissingScope, } -impl Rejection { - fn status_code(&self) -> StatusCode { - match self { - Self::InvalidAuthorizationHeader | Self::MissingAuthorizationHeader => { - StatusCode::BAD_REQUEST - } - Self::UnknownAccessToken - | Self::TokenExpired - | Self::SessionRevoked - | Self::UserLocked - | Self::MissingScope => StatusCode::UNAUTHORIZED, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - impl IntoResponse for Rejection { fn into_response(self) -> Response { let response = ErrorResponse::from_error(&self); - let status = self.status_code(); - (status, Json(response)).into_response() + let sentry_event_id = record_error!( + self, + Self::RepositorySetup(_) + | Self::Repository(_) + | Self::LoadSession(_) + | Self::LoadUser(_) + ); + + let status = match &self { + Rejection::InvalidAuthorizationHeader | Rejection::MissingAuthorizationHeader => { + StatusCode::BAD_REQUEST + } + + Rejection::UnknownAccessToken + | Rejection::TokenExpired + | Rejection::SessionRevoked + | Rejection::UserLocked + | Rejection::MissingScope => StatusCode::UNAUTHORIZED, + + Rejection::RepositorySetup(_) + | Rejection::Repository(_) + | Rejection::LoadSession(_) + | Rejection::LoadUser(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + + (status, sentry_event_id, Json(response)).into_response() } } diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 4c1305b13..f0187e246 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -19,7 +19,7 @@ use axum::{ }; use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use indexmap::IndexMap; -use mas_axum_utils::FancyError; +use mas_axum_utils::InternalError; use mas_http::CorsLayerExt; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; @@ -81,35 +81,61 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { ), ..Default::default() }) + .security_scheme("oauth2", oauth_security_scheme(None)) .security_scheme( - "oauth2", - SecurityScheme::OAuth2 { - flows: OAuth2Flows { - client_credentials: Some(OAuth2Flow::ClientCredentials { - refresh_url: Some(OAuth2TokenEndpoint::PATH.to_owned()), - token_url: OAuth2TokenEndpoint::PATH.to_owned(), - scopes: IndexMap::from([( - "urn:mas:admin".to_owned(), - "Grant access to the admin API".to_owned(), - )]), - }), - authorization_code: Some(OAuth2Flow::AuthorizationCode { - authorization_url: OAuth2AuthorizationEndpoint::PATH.to_owned(), - refresh_url: Some(OAuth2TokenEndpoint::PATH.to_owned()), - token_url: OAuth2TokenEndpoint::PATH.to_owned(), - scopes: IndexMap::from([( - "urn:mas:admin".to_owned(), - "Grant access to the admin API".to_owned(), - )]), - }), - implicit: None, - password: None, - }, - description: None, + "token", + SecurityScheme::Http { + scheme: "bearer".to_owned(), + bearer_format: None, + description: Some("An access token with access to the admin API".to_owned()), extensions: IndexMap::default(), }, ) .security_requirement_scopes("oauth2", ["urn:mas:admin"]) + .security_requirement_scopes("bearer", ["urn:mas:admin"]) +} + +fn oauth_security_scheme(url_builder: Option<&UrlBuilder>) -> SecurityScheme { + let (authorization_url, token_url) = if let Some(url_builder) = url_builder { + ( + url_builder.oauth_authorization_endpoint().to_string(), + url_builder.oauth_token_endpoint().to_string(), + ) + } else { + // This is a dirty fix for Swagger UI: when it joins the URLs with the + // base URL, if the path starts with a slash, it will go to the root of + // the domain instead of the API root. + // It works if we make it explicitly relative + ( + format!(".{}", OAuth2AuthorizationEndpoint::PATH), + format!(".{}", OAuth2TokenEndpoint::PATH), + ) + }; + + let scopes = IndexMap::from([( + "urn:mas:admin".to_owned(), + "Grant access to the admin API".to_owned(), + )]); + + SecurityScheme::OAuth2 { + flows: OAuth2Flows { + client_credentials: Some(OAuth2Flow::ClientCredentials { + refresh_url: Some(token_url.clone()), + token_url: token_url.clone(), + scopes: scopes.clone(), + }), + authorization_code: Some(OAuth2Flow::AuthorizationCode { + authorization_url, + refresh_url: Some(token_url.clone()), + token_url, + scopes, + }), + implicit: None, + password: None, + }, + description: None, + extensions: IndexMap::default(), + } } pub fn router() -> (OpenApi, Router) @@ -146,10 +172,13 @@ where move |State(url_builder): State| { // Let's set the servers to the HTTP base URL let mut api = api.clone(); - api.servers = vec![Server { - url: url_builder.http_base().to_string(), - ..Server::default() - }]; + + let _ = TransformOpenApi::new(&mut api) + .server(Server { + url: url_builder.http_base().to_string(), + ..Server::default() + }) + .security_scheme("oauth2", oauth_security_scheme(Some(&url_builder))); std::future::ready(Json(api)) } @@ -180,7 +209,7 @@ where async fn swagger( State(url_builder): State, State(templates): State, -) -> Result, FancyError> { +) -> Result, InternalError> { let ctx = ApiDocContext::from_url_builder(&url_builder); let res = templates.render_swagger(&ctx)?; Ok(Html(res)) @@ -189,7 +218,7 @@ async fn swagger( async fn swagger_callback( State(url_builder): State, State(templates): State, -) -> Result, FancyError> { +) -> Result, InternalError> { let ctx = ApiDocContext::from_url_builder(&url_builder); let res = templates.render_swagger_callback(&ctx)?; Ok(Html(res)) diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index c3e81c627..df17e2d91 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -184,6 +184,9 @@ pub struct CompatSession { /// The time this session was finished pub finished_at: Option>, + + /// The user-provided name, if any + pub human_name: Option, } impl @@ -206,10 +209,11 @@ impl user_session_id: session.user_session_id, redirect_uri: sso_login.map(|sso| sso.redirect_uri), created_at: session.created_at, - user_agent: session.user_agent.map(|ua| ua.raw), + user_agent: session.user_agent, last_active_at: session.last_active_at, last_active_ip: session.last_active_ip, finished_at, + human_name: session.human_name, } } } @@ -237,6 +241,7 @@ impl CompatSession { last_active_at: Some(DateTime::default()), last_active_ip: Some([1, 2, 3, 4].into()), finished_at: None, + human_name: Some("Laptop".to_owned()), }, Self { id: Ulid::from_bytes([0x02; 16]), @@ -249,6 +254,7 @@ impl CompatSession { last_active_at: Some(DateTime::default()), last_active_ip: Some([1, 2, 3, 4].into()), finished_at: Some(DateTime::default()), + human_name: None, }, Self { id: Ulid::from_bytes([0x03; 16]), @@ -261,6 +267,7 @@ impl CompatSession { last_active_at: None, last_active_ip: None, finished_at: None, + human_name: None, }, ] } @@ -301,6 +308,9 @@ pub struct OAuth2Session { /// The last IP address used by the session last_active_ip: Option, + + /// The user-provided name, if any + human_name: Option, } impl From for OAuth2Session { @@ -313,9 +323,10 @@ impl From for OAuth2Session { user_session_id: session.user_session_id, client_id: session.client_id, scope: session.scope.to_string(), - user_agent: session.user_agent.map(|ua| ua.raw), + user_agent: session.user_agent, last_active_at: session.last_active_at, last_active_ip: session.last_active_ip, + human_name: session.human_name, } } } @@ -335,6 +346,7 @@ impl OAuth2Session { user_agent: Some("Mozilla/5.0".to_owned()), last_active_at: Some(DateTime::default()), last_active_ip: Some("127.0.0.1".parse().unwrap()), + human_name: Some("Laptop".to_owned()), }, Self { id: Ulid::from_bytes([0x02; 16]), @@ -347,6 +359,7 @@ impl OAuth2Session { user_agent: None, last_active_at: None, last_active_ip: None, + human_name: None, }, Self { id: Ulid::from_bytes([0x03; 16]), @@ -359,6 +372,7 @@ impl OAuth2Session { user_agent: Some("Mozilla/5.0".to_owned()), last_active_at: Some(DateTime::default()), last_active_ip: Some("127.0.0.1".parse().unwrap()), + human_name: None, }, ] } @@ -406,7 +420,7 @@ impl From for UserSession { created_at: value.created_at, finished_at: value.finished_at, user_id: value.user.id, - user_agent: value.user_agent.map(|ua| ua.raw), + user_agent: value.user_agent, last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, } diff --git a/crates/handlers/src/admin/v1/compat_sessions/get.rs b/crates/handlers/src/admin/v1/compat_sessions/get.rs index 16a720849..3d471d0ce 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/get.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/get.rs @@ -6,6 +6,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -33,11 +34,13 @@ 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 status = match self { + let sentry_event_id = record_error!(self, RouteError::Internal(_)); + let status = match &self { Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::NotFound(_) => StatusCode::NOT_FOUND, }; - (status, Json(error)).into_response() + + (status, sentry_event_id, Json(error)).into_response() } } @@ -59,7 +62,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.compat_sessions.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, @@ -104,7 +107,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &state.clock, &user, device, None, false) + .add(&mut rng, &state.clock, &user, device, None, false, None) .await .unwrap(); repo.save().await.unwrap(); @@ -116,7 +119,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#" { "data": { "type": "compat-session", @@ -130,7 +133,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07" @@ -140,7 +144,7 @@ mod tests { "self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07" } } - "###); + "#); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index f08fcae1c..adf15d190 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -11,6 +11,7 @@ use axum::{ }; use axum_macros::FromRequestParts; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_storage::{Page, compat::CompatSessionFilter}; use schemars::JsonSchema; use serde::Deserialize; @@ -113,12 +114,14 @@ 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 status = match self { + let sentry_event_id = record_error!(self, RouteError::Internal(_)); + let status = match &self { Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::UserNotFound(_) | Self::UserSessionNotFound(_) => StatusCode::NOT_FOUND, Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, }; - (status, Json(error)).into_response() + + (status, sentry_event_id, Json(error)).into_response() } } @@ -153,7 +156,7 @@ 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, err)] +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Pagination(pagination): Pagination, @@ -248,7 +251,7 @@ mod tests { let device = Device::generate(&mut rng); repo.compat_session() - .add(&mut rng, &state.clock, &alice, device, None, false) + .add(&mut rng, &state.clock, &alice, device, None, false, None) .await .unwrap(); let device = Device::generate(&mut rng); @@ -257,7 +260,7 @@ mod tests { let session = repo .compat_session() - .add(&mut rng, &state.clock, &bob, device, None, false) + .add(&mut rng, &state.clock, &bob, device, None, false, None) .await .unwrap(); state.clock.advance(Duration::minutes(1)); @@ -273,7 +276,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 @@ -291,7 +294,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" @@ -309,7 +313,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": "2022-01-16T14:43:00Z" + "finished_at": "2022-01-16T14:43:00Z", + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" @@ -322,7 +327,7 @@ mod tests { "last": "/api/admin/v1/compat-sessions?page[last]=10" } } - "###); + "#); // Filter by user let request = Request::get(format!( @@ -334,7 +339,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,7 +357,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" @@ -365,7 +371,7 @@ mod tests { "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by status (active) let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=active") @@ -374,7 +380,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 @@ -392,7 +398,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" @@ -405,7 +412,7 @@ mod tests { "last": "/api/admin/v1/compat-sessions?filter[status]=active&page[last]=10" } } - "###); + "#); // Filter by status (finished) let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=finished") @@ -414,7 +421,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 @@ -432,7 +439,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": "2022-01-16T14:43:00Z" + "finished_at": "2022-01-16T14:43:00Z", + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" @@ -445,6 +453,6 @@ mod tests { "last": "/api/admin/v1/compat-sessions?filter[status]=finished&page[last]=10" } } - "###); + "#); } } diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/get.rs b/crates/handlers/src/admin/v1/oauth2_sessions/get.rs index e8fce4a0a..88f46ecff 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/get.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/get.rs @@ -7,6 +7,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -34,11 +35,12 @@ 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, RouteError::Internal(_)); let status = match self { Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::NotFound(_) => StatusCode::NOT_FOUND, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -60,7 +62,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.oauth2_session.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.oauth2_session.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, @@ -108,7 +110,7 @@ mod tests { response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); assert_eq!(body["data"]["type"], "oauth2-session"); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "data": { "type": "oauth2-session", @@ -122,7 +124,8 @@ mod tests { "scope": "urn:mas:admin", "user_agent": null, "last_active_at": null, - "last_active_ip": null + "last_active_ip": null, + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" @@ -132,7 +135,7 @@ mod tests { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" } } - "###); + "#); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs index eeccfc12b..49b429243 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -14,6 +14,7 @@ use axum::{ }; use axum_macros::FromRequestParts; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_storage::{Page, oauth2::OAuth2SessionFilter}; use oauth2_types::scope::{Scope, ScopeToken}; use schemars::JsonSchema; @@ -167,6 +168,7 @@ 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, RouteError::Internal(_)); let status = match self { Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::UserNotFound(_) | Self::ClientNotFound(_) | Self::UserSessionNotFound(_) => { @@ -174,7 +176,7 @@ impl IntoResponse for RouteError { } Self::InvalidScope(_) | Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -213,7 +215,7 @@ 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, err)] +#[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Pagination(pagination): Pagination, @@ -329,7 +331,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 @@ -347,7 +349,8 @@ mod tests { "scope": "urn:mas:admin", "user_agent": null, "last_active_at": null, - "last_active_ip": null + "last_active_ip": null, + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" @@ -360,6 +363,6 @@ mod tests { "last": "/api/admin/v1/oauth2-sessions?page[last]=10" } } - "###); + "#); } } diff --git a/crates/handlers/src/admin/v1/policy_data/get.rs b/crates/handlers/src/admin/v1/policy_data/get.rs index 338c999b3..51d8c7849 100644 --- a/crates/handlers/src/admin/v1/policy_data/get.rs +++ b/crates/handlers/src/admin/v1/policy_data/get.rs @@ -5,6 +5,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -32,11 +33,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -56,7 +58,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.policy_data.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.policy_data.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, 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 7b4c0654f..f217b30dc 100644 --- a/crates/handlers/src/admin/v1/policy_data/get_latest.rs +++ b/crates/handlers/src/admin/v1/policy_data/get_latest.rs @@ -5,6 +5,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use crate::{ admin::{ @@ -30,11 +31,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -55,7 +57,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.policy_data.get_latest", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.policy_data.get_latest", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, ) -> Result>, RouteError> { diff --git a/crates/handlers/src/admin/v1/policy_data/set.rs b/crates/handlers/src/admin/v1/policy_data/set.rs index b857b488b..bc28e96e3 100644 --- a/crates/handlers/src/admin/v1/policy_data/set.rs +++ b/crates/handlers/src/admin/v1/policy_data/set.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_policy::PolicyFactory; use mas_storage::BoxRng; use schemars::JsonSchema; @@ -36,11 +37,12 @@ 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 { RouteError::InvalidPolicyData(_) => StatusCode::BAD_REQUEST, RouteError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -79,7 +81,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.policy_data.set", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.policy_data.set", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. 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 a9a7c461f..3cdbc783f 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs @@ -6,6 +6,7 @@ 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 schemars::JsonSchema; use serde::Deserialize; @@ -41,12 +42,13 @@ 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::LinkAlreadyExists(_, _) => StatusCode::CONFLICT, Self::UserNotFound(_) | Self::ProviderNotFound(_) => StatusCode::NOT_FOUND, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -102,7 +104,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.post", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.post", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. 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 51540de0f..403c1bc33 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs @@ -6,6 +6,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -28,11 +29,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -49,7 +51,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.delete", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.delete", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. @@ -106,14 +108,7 @@ mod tests { // Pretend it was linked by an authorization session let session = repo .upstream_oauth_session() - .add( - &mut rng, - &state.clock, - &provider, - String::new(), - None, - String::new(), - ) + .add(&mut rng, &state.clock, &provider, String::new(), 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 eecbb3e4e..4fd5158d3 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs @@ -6,6 +6,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -33,11 +34,12 @@ 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_entry_id = record_error!(self, Self::Internal(_)); let status = match self { Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::NotFound(_) => StatusCode::NOT_FOUND, }; - (status, Json(error)).into_response() + (status, sentry_entry_id, Json(error)).into_response() } } @@ -59,7 +61,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, 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 e20a975be..e46b85820 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs @@ -11,6 +11,7 @@ use axum::{ }; use axum_macros::FromRequestParts; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_storage::{Page, upstream_oauth2::UpstreamOAuthLinkFilter}; use schemars::JsonSchema; use serde::Deserialize; @@ -91,12 +92,13 @@ 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::UserNotFound(_) | Self::ProviderNotFound(_) => StatusCode::NOT_FOUND, Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -130,7 +132,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Pagination(pagination): Pagination, 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 12792e3a6..6696a7109 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -47,6 +47,7 @@ mod test_utils { userinfo_endpoint_override: None, jwks_uri_override: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, ui_order: 0, } } diff --git a/crates/handlers/src/admin/v1/user_emails/add.rs b/crates/handlers/src/admin/v1/user_emails/add.rs index 466d372be..f3a39e20a 100644 --- a/crates/handlers/src/admin/v1/user_emails/add.rs +++ b/crates/handlers/src/admin/v1/user_emails/add.rs @@ -8,6 +8,7 @@ use std::str::FromStr as _; 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 _}, @@ -52,13 +53,14 @@ 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::EmailAlreadyInUse(_) => StatusCode::CONFLICT, Self::EmailNotValid { .. } => StatusCode::BAD_REQUEST, Self::UserNotFound(_) => StatusCode::NOT_FOUND, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -106,7 +108,7 @@ Note that this endpoint ignores any policy which would normally prevent the emai }) } -#[tracing::instrument(name = "handler.admin.v1.user_emails.add", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.user_emails.add", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. diff --git a/crates/handlers/src/admin/v1/user_emails/delete.rs b/crates/handlers/src/admin/v1/user_emails/delete.rs index 65e000111..ad7df7acb 100644 --- a/crates/handlers/src/admin/v1/user_emails/delete.rs +++ b/crates/handlers/src/admin/v1/user_emails/delete.rs @@ -6,6 +6,7 @@ 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 _}, @@ -32,11 +33,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -52,7 +54,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.user_emails.delete", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.user_emails.delete", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. diff --git a/crates/handlers/src/admin/v1/user_emails/get.rs b/crates/handlers/src/admin/v1/user_emails/get.rs index b5ddfc02a..9232b0663 100644 --- a/crates/handlers/src/admin/v1/user_emails/get.rs +++ b/crates/handlers/src/admin/v1/user_emails/get.rs @@ -6,6 +6,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -33,11 +34,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -57,7 +59,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.user_emails.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.user_emails.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, diff --git a/crates/handlers/src/admin/v1/user_emails/list.rs b/crates/handlers/src/admin/v1/user_emails/list.rs index d7ffa9ce5..f7adb23da 100644 --- a/crates/handlers/src/admin/v1/user_emails/list.rs +++ b/crates/handlers/src/admin/v1/user_emails/list.rs @@ -11,6 +11,7 @@ use axum::{ }; use axum_macros::FromRequestParts; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_storage::{Page, user::UserEmailFilter}; use schemars::JsonSchema; use serde::Deserialize; @@ -78,12 +79,13 @@ 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::UserNotFound(_) => StatusCode::NOT_FOUND, Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -116,7 +118,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Pagination(pagination): Pagination, diff --git a/crates/handlers/src/admin/v1/user_sessions/get.rs b/crates/handlers/src/admin/v1/user_sessions/get.rs index 1396beb47..a59b10d0e 100644 --- a/crates/handlers/src/admin/v1/user_sessions/get.rs +++ b/crates/handlers/src/admin/v1/user_sessions/get.rs @@ -6,6 +6,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -33,11 +34,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -58,7 +60,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.user_sessions.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.user_sessions.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, diff --git a/crates/handlers/src/admin/v1/user_sessions/list.rs b/crates/handlers/src/admin/v1/user_sessions/list.rs index 62b72cc50..a04bf057f 100644 --- a/crates/handlers/src/admin/v1/user_sessions/list.rs +++ b/crates/handlers/src/admin/v1/user_sessions/list.rs @@ -11,6 +11,7 @@ use axum::{ }; use axum_macros::FromRequestParts; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_storage::{pagination::Page, user::BrowserSessionFilter}; use schemars::JsonSchema; use serde::Deserialize; @@ -100,12 +101,13 @@ 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::UserNotFound(_) => StatusCode::NOT_FOUND, Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -140,7 +142,7 @@ 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, err)] +#[tracing::instrument(name = "handler.admin.v1.user_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Pagination(pagination): Pagination, diff --git a/crates/handlers/src/admin/v1/users/add.rs b/crates/handlers/src/admin/v1/users/add.rs index d6c83db51..9867b06ec 100644 --- a/crates/handlers/src/admin/v1/users/add.rs +++ b/crates/handlers/src/admin/v1/users/add.rs @@ -9,6 +9,7 @@ use std::sync::Arc; 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, @@ -81,12 +82,13 @@ 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::UsernameNotValid => StatusCode::BAD_REQUEST, Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -131,7 +133,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.add", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.add", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. diff --git a/crates/handlers/src/admin/v1/users/by_username.rs b/crates/handlers/src/admin/v1/users/by_username.rs index 66fc6ff51..98ddb8b3c 100644 --- a/crates/handlers/src/admin/v1/users/by_username.rs +++ b/crates/handlers/src/admin/v1/users/by_username.rs @@ -7,6 +7,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, extract::Path, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use schemars::JsonSchema; use serde::Deserialize; @@ -34,11 +35,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -65,7 +67,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.by_username", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.by_username", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Path(UsernamePathParam { username }): Path, diff --git a/crates/handlers/src/admin/v1/users/deactivate.rs b/crates/handlers/src/admin/v1/users/deactivate.rs index 25d4a0339..fad2f5257 100644 --- a/crates/handlers/src/admin/v1/users/deactivate.rs +++ b/crates/handlers/src/admin/v1/users/deactivate.rs @@ -7,6 +7,7 @@ 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 _}, @@ -39,11 +40,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -67,7 +69,7 @@ This invalidates any existing session, and will ask the homeserver to make them }) } -#[tracing::instrument(name = "handler.admin.v1.users.deactivate", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.deactivate", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. @@ -86,7 +88,7 @@ pub async fn handler( user = repo.user().lock(&clock, user).await?; } - info!("Scheduling deactivation of user {}", user.id); + info!(%user.id, "Scheduling deactivation of user"); repo.queue_job() .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, true)) .await?; diff --git a/crates/handlers/src/admin/v1/users/get.rs b/crates/handlers/src/admin/v1/users/get.rs index 71763b292..59d221fbb 100644 --- a/crates/handlers/src/admin/v1/users/get.rs +++ b/crates/handlers/src/admin/v1/users/get.rs @@ -7,6 +7,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -34,11 +35,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -58,7 +60,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.get", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.get", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs index 628b56747..17bd82ede 100644 --- a/crates/handlers/src/admin/v1/users/list.rs +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -12,6 +12,7 @@ use axum::{ }; use axum_macros::FromRequestParts; use hyper::StatusCode; +use mas_axum_utils::record_error; use mas_storage::{Page, user::UserFilter}; use schemars::JsonSchema; use serde::Deserialize; @@ -95,11 +96,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -122,7 +124,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.list", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, Pagination(pagination): Pagination, diff --git a/crates/handlers/src/admin/v1/users/lock.rs b/crates/handlers/src/admin/v1/users/lock.rs index 99ae7c5a4..13ffdc071 100644 --- a/crates/handlers/src/admin/v1/users/lock.rs +++ b/crates/handlers/src/admin/v1/users/lock.rs @@ -7,6 +7,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use ulid::Ulid; use crate::{ @@ -34,11 +35,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -62,7 +64,7 @@ This DOES NOT invalidate any existing session, meaning that all their existing s }) } -#[tracing::instrument(name = "handler.admin.v1.users.lock", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.lock", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. diff --git a/crates/handlers/src/admin/v1/users/set_admin.rs b/crates/handlers/src/admin/v1/users/set_admin.rs index fae62d5c0..72df1f71b 100644 --- a/crates/handlers/src/admin/v1/users/set_admin.rs +++ b/crates/handlers/src/admin/v1/users/set_admin.rs @@ -7,6 +7,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; +use mas_axum_utils::record_error; use schemars::JsonSchema; use serde::Deserialize; use ulid::Ulid; @@ -36,11 +37,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -71,7 +73,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.set_admin", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.set_admin", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, diff --git a/crates/handlers/src/admin/v1/users/set_password.rs b/crates/handlers/src/admin/v1/users/set_password.rs index 4a0e6d034..2d83e39d9 100644 --- a/crates/handlers/src/admin/v1/users/set_password.rs +++ b/crates/handlers/src/admin/v1/users/set_password.rs @@ -7,6 +7,7 @@ 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 schemars::JsonSchema; use serde::Deserialize; @@ -43,13 +44,14 @@ 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::Password(_)); let status = match self { Self::Internal(_) | Self::Password(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::PasswordAuthDisabled => StatusCode::FORBIDDEN, Self::PasswordTooWeak => StatusCode::BAD_REQUEST, Self::NotFound(_) => StatusCode::NOT_FOUND, }; - (status, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -90,7 +92,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.set_password", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.set_password", skip_all)] pub async fn handler( CallContext { mut repo, clock, .. diff --git a/crates/handlers/src/admin/v1/users/unlock.rs b/crates/handlers/src/admin/v1/users/unlock.rs index 76bb738c3..e1811378c 100644 --- a/crates/handlers/src/admin/v1/users/unlock.rs +++ b/crates/handlers/src/admin/v1/users/unlock.rs @@ -9,6 +9,7 @@ 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; @@ -40,11 +41,12 @@ 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, Json(error)).into_response() + (status, sentry_event_id, Json(error)).into_response() } } @@ -66,7 +68,7 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { }) } -#[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all, err)] +#[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, State(homeserver): State>, diff --git a/crates/handlers/src/captcha.rs b/crates/handlers/src/captcha.rs index fec2a7573..740995145 100644 --- a/crates/handlers/src/captcha.rs +++ b/crates/handlers/src/captcha.rs @@ -156,7 +156,6 @@ impl Form { skip_all, name = "captcha.verify", fields(captcha.hostname, captcha.challenge_ts, captcha.service), - err )] pub async fn verify( &self, diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 9c9312855..3dbbb0d93 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -10,17 +10,16 @@ use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; use chrono::Duration; use hyper::StatusCode; -use mas_axum_utils::sentry::SentryEventID; -use mas_data_model::{ - CompatSession, CompatSsoLoginState, Device, SiteConfig, TokenType, User, UserAgent, -}; +use mas_axum_utils::record_error; +use mas_data_model::{CompatSession, CompatSsoLoginState, Device, SiteConfig, TokenType, User}; use mas_matrix::HomeserverConnection; use mas_storage::{ - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, + BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, Clock, RepositoryAccess, compat::{ CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository, CompatSsoLoginRepository, }, + queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, user::{UserPasswordRepository, UserRepository}, }; use opentelemetry::{Key, KeyValue, metrics::Counter}; @@ -118,6 +117,9 @@ pub struct RequestBody { /// this is not specified. #[serde(default, skip_serializing_if = "Option::is_none")] device_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + initial_device_display_name: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -210,7 +212,8 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = + record_error!(self, Self::Internal(_) | Self::ProvisionDeviceFailed(_)); LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); let response = match self { Self::Internal(_) | Self::ProvisionDeviceFailed(_) => MatrixError { @@ -257,16 +260,16 @@ impl IntoResponse for RouteError { }, }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } -#[tracing::instrument(name = "handlers.compat.login.post", skip_all, err)] +#[tracing::instrument(name = "handlers.compat.login.post", skip_all)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, State(password_manager): State, - mut repo: BoxRepository, + State(repository_factory): State, activity_tracker: BoundActivityTracker, State(homeserver): State>, State(site_config): State, @@ -275,8 +278,9 @@ pub(crate) async fn post( user_agent: Option>, MatrixJsonBody(input): MatrixJsonBody, ) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let login_type = input.credentials.login_type(); + let mut repo = repository_factory.create().await?; let (mut session, user) = match (password_manager.is_enabled(), input.credentials) { ( true, @@ -299,6 +303,9 @@ pub(crate) async fn post( } }; + // Try getting the localpart out of the MXID + let username = homeserver.localpart(&user).unwrap_or(&user); + user_password_login( &mut rng, &clock, @@ -306,22 +313,22 @@ pub(crate) async fn post( &limiter, requester, &mut repo, - &homeserver, - user, + username, password, input.device_id, // TODO check for validity + input.initial_device_display_name, ) .await? } (_, Credentials::Token { token }) => { token_login( - &mut repo, + &mut rng, &clock, + &mut repo, &token, input.device_id, - &homeserver, - &mut rng, + input.initial_device_display_name, ) .await? } @@ -364,12 +371,53 @@ pub(crate) async fn post( None }; + // Ideally, we'd keep the lock whilst we actually create the device, but we + // really want to stop holding the transaction while we talk to the + // homeserver. + // + // In practice, this is fine, because: + // - the session exists after we commited the transaction, so a sync job won't + // try to delete it + // - we've acquired a lock on the user before creating the session, meaning + // we've made sure that sync jobs finished before we create the new session + // - we're in the read-commited isolation level, which means the sync will see + // what we've committed and won't try to delete the session once we release + // the lock repo.save().await?; activity_tracker .record_compat_session(&clock, &session) .await; + // This session will have for sure the device on it, both methods create a + // device + let Some(device) = &session.device else { + unreachable!() + }; + + // 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()) + .await + { + // Something went wrong, let's end this session and schedule a device sync + let mut repo = repository_factory.create().await?; + let session = repo.compat_session().finish(&clock, session).await?; + + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SyncDevicesJob::new_for_id(session.user_id), + ) + .await?; + + repo.save().await?; + + return Err(RouteError::ProvisionDeviceFailed(err)); + } + LOGIN_COUNTER.add( 1, &[ @@ -388,12 +436,12 @@ pub(crate) async fn post( } async fn token_login( - repo: &mut BoxRepository, + rng: &mut (dyn RngCore + Send), clock: &dyn Clock, + repo: &mut BoxRepository, token: &str, requested_device_id: Option, - homeserver: &dyn HomeserverConnection, - rng: &mut (dyn RngCore + Send), + initial_device_display_name: Option, ) -> Result<(CompatSession, User), RouteError> { let login = repo .compat_sso_login() @@ -456,7 +504,8 @@ async fn token_login( return Err(RouteError::InvalidLoginToken); } - // Lock the user sync to make sure we don't get into a race condition + // We're about to create a device, let's explicitly acquire a lock, so that + // any concurrent sync will read after we've committed repo.user() .acquire_lock_for_sync(&browser_session.user) .await?; @@ -466,16 +515,14 @@ async fn token_login( } else { Device::generate(rng) }; - let mxid = homeserver.mxid(&browser_session.user.username); - homeserver - .create_device(&mxid, device.as_str()) - .await - .map_err(RouteError::ProvisionDeviceFailed)?; repo.app_session() .finish_sessions_to_replace_device(clock, &browser_session.user, &device) .await?; + // We first create the session in the database, commit the transaction, then + // create it on the homeserver, scheduling a device sync job afterwards to + // make sure we don't end up in an inconsistent state. let compat_session = repo .compat_session() .add( @@ -485,6 +532,7 @@ async fn token_login( device, Some(&browser_session), false, + initial_device_display_name, ) .await?; @@ -502,14 +550,11 @@ async fn user_password_login( limiter: &Limiter, requester: RequesterFingerprint, repo: &mut BoxRepository, - homeserver: &dyn HomeserverConnection, - username: String, + username: &str, password: String, requested_device_id: Option, + initial_device_display_name: Option, ) -> Result<(CompatSession, User), RouteError> { - // Try getting the localpart out of the MXID - let username = homeserver.localpart(&username).unwrap_or(&username); - // Find the user let user = repo .user() @@ -555,21 +600,16 @@ async fn user_password_login( .await?; } - // Lock the user sync to make sure we don't get into a race condition + // We're about to create a device, let's explicitly acquire a lock, so that + // any concurrent sync will read after we've committed repo.user().acquire_lock_for_sync(&user).await?; - let mxid = homeserver.mxid(&user.username); - // Now that the user credentials have been verified, start a new compat session let device = if let Some(requested_device_id) = requested_device_id { Device::from(requested_device_id) } else { Device::generate(&mut rng) }; - homeserver - .create_device(&mxid, device.as_str()) - .await - .map_err(RouteError::ProvisionDeviceFailed)?; repo.app_session() .finish_sessions_to_replace_device(clock, &user, &device) @@ -577,7 +617,15 @@ async fn user_password_login( let session = repo .compat_session() - .add(&mut rng, clock, &user, device, None, false) + .add( + &mut rng, + clock, + &user, + device, + None, + false, + initial_device_display_name, + ) .await?; Ok((session, user)) diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 8da507d70..15da5fb4d 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -13,7 +13,7 @@ use axum::{ }; use chrono::Duration; use mas_axum_utils::{ - FancyError, + InternalError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -48,7 +48,6 @@ pub struct Params { name = "handlers.compat.login_sso_complete.get", fields(compat_sso_login.id = %id), skip_all, - err, )] pub async fn get( PreferredLanguage(locale): PreferredLanguage, @@ -60,7 +59,7 @@ pub async fn get( cookie_jar: CookieJar, Path(id): Path, Query(params): Query, -) -> Result { +) -> Result { let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -94,7 +93,8 @@ pub async fn get( .compat_sso_login() .lookup(id) .await? - .context("Could not find compat SSO login")?; + .context("Could not find compat SSO login") + .map_err(InternalError::from_anyhow)?; // Bail out if that login session is more than 30min old if clock.now() > login.created_at + Duration::microseconds(30 * 60 * 1000 * 1000) { @@ -121,7 +121,6 @@ pub async fn get( name = "handlers.compat.login_sso_complete.post", fields(compat_sso_login.id = %id), skip_all, - err, )] pub async fn post( mut rng: BoxRng, @@ -134,7 +133,7 @@ pub async fn post( Path(id): Path, Query(params): Query, Form(form): Form>, -) -> Result { +) -> Result { let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -168,7 +167,8 @@ pub async fn post( .compat_sso_login() .lookup(id) .await? - .context("Could not find compat SSO login")?; + .context("Could not find compat SSO login") + .map_err(InternalError::from_anyhow)?; // Bail out if that login session isn't pending, or is more than 30min old if !login.is_pending() diff --git a/crates/handlers/src/compat/login_sso_redirect.rs b/crates/handlers/src/compat/login_sso_redirect.rs index 36b065b39..583f24e9b 100644 --- a/crates/handlers/src/compat/login_sso_redirect.rs +++ b/crates/handlers/src/compat/login_sso_redirect.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, }; use hyper::StatusCode; -use mas_axum_utils::sentry::SentryEventID; +use mas_axum_utils::record_error; use mas_router::{CompatLoginSsoAction, CompatLoginSsoComplete, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, BoxRng, compat::CompatSsoLoginRepository}; use rand::distributions::{Alphanumeric, DistString}; @@ -43,17 +43,16 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); - ( - StatusCode::INTERNAL_SERVER_ERROR, - SentryEventID::from(event_id), - format!("{self}"), - ) - .into_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() } } -#[tracing::instrument(name = "handlers.compat.login_sso_redirect.get", skip_all, err)] +#[tracing::instrument(name = "handlers.compat.login_sso_redirect.get", skip_all)] pub async fn get( mut rng: BoxRng, clock: BoxClock, diff --git a/crates/handlers/src/compat/logout.rs b/crates/handlers/src/compat/logout.rs index 557dbaef9..7b2ea7d52 100644 --- a/crates/handlers/src/compat/logout.rs +++ b/crates/handlers/src/compat/logout.rs @@ -10,7 +10,7 @@ use axum::{Json, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; -use mas_axum_utils::sentry::SentryEventID; +use mas_axum_utils::record_error; use mas_data_model::TokenType; use mas_storage::{ BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, @@ -51,7 +51,7 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); LOGOUT_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); let response = match self { Self::Internal(_) => MatrixError { @@ -71,11 +71,11 @@ impl IntoResponse for RouteError { }, }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } -#[tracing::instrument(name = "handlers.compat.logout.post", skip_all, err)] +#[tracing::instrument(name = "handlers.compat.logout.post", skip_all)] pub(crate) async fn post( clock: BoxClock, mut rng: BoxRng, diff --git a/crates/handlers/src/compat/logout_all.rs b/crates/handlers/src/compat/logout_all.rs new file mode 100644 index 000000000..489521313 --- /dev/null +++ b/crates/handlers/src/compat/logout_all.rs @@ -0,0 +1,201 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::sync::LazyLock; + +use axum::{Json, response::IntoResponse}; +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_storage::{ + BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, + compat::{CompatAccessTokenRepository, CompatSessionFilter, CompatSessionRepository}, + queue::{QueueJobRepositoryExt as _, SyncDevicesJob}, +}; +use opentelemetry::{Key, KeyValue, metrics::Counter}; +use serde::Deserialize; +use thiserror::Error; +use tracing::info; +use ulid::Ulid; + +use super::{MatrixError, MatrixJsonBody}; +use crate::{BoundActivityTracker, METER, impl_from_error_for_route}; + +static LOGOUT_ALL_COUNTER: LazyLock> = LazyLock::new(|| { + METER + .u64_counter("mas.compat.logout_all_request") + .with_description( + "How many request to the /logout/all compatibility endpoint have happened", + ) + .with_unit("{request}") + .build() +}); +const RESULT: Key = Key::from_static_str("result"); + +#[derive(Error, Debug)] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Can't load session {0}")] + CantLoadSession(Ulid), + + #[error("Can't load user {0}")] + CantLoadUser(Ulid), + + #[error("Token {0} has expired")] + InvalidToken(Ulid), + + #[error("Session {0} has been revoked")] + InvalidSession(Ulid), + + #[error("User {0} is locked or deactivated")] + InvalidUser(Ulid), + + #[error("/logout/all is not supported")] + NotSupported, + + #[error("Missing access token")] + MissingAuthorization, + + #[error("Invalid token format")] + TokenFormat(#[from] mas_data_model::TokenFormatError), + + #[error("Access token is not a compatibility access token")] + NotACompatToken, +} + +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(_) | Self::CantLoadSession(_) | Self::CantLoadUser(_) + ); + + // We track separately if the endpoint was called without the custom + // parameter, so that we know if clients are using this endpoint in the + // wild + if matches!(self, Self::NotSupported) { + LOGOUT_ALL_COUNTER.add(1, &[KeyValue::new(RESULT, "not_supported")]); + } else { + LOGOUT_ALL_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); + } + + let response = match self { + Self::Internal(_) | Self::CantLoadSession(_) | Self::CantLoadUser(_) => MatrixError { + errcode: "M_UNKNOWN", + error: "Internal error", + status: StatusCode::INTERNAL_SERVER_ERROR, + }, + Self::MissingAuthorization => MatrixError { + errcode: "M_MISSING_TOKEN", + error: "Missing access token", + status: StatusCode::UNAUTHORIZED, + }, + Self::InvalidUser(_) + | Self::InvalidSession(_) + | Self::InvalidToken(_) + | Self::NotACompatToken + | Self::TokenFormat(_) => MatrixError { + errcode: "M_UNKNOWN_TOKEN", + error: "Invalid access token", + status: StatusCode::UNAUTHORIZED, + }, + Self::NotSupported => MatrixError { + errcode: "M_UNRECOGNIZED", + error: "The /logout/all endpoint is not supported by this deployment", + status: StatusCode::NOT_FOUND, + }, + }; + + (sentry_event_id, response).into_response() + } +} + +#[derive(Deserialize, Default)] +pub(crate) struct RequestBody { + #[serde(rename = "io.element.only_compat_is_fine", default)] + only_compat_is_fine: bool, +} + +#[tracing::instrument(name = "handlers.compat.logout_all.post", skip_all)] +pub(crate) async fn post( + clock: BoxClock, + mut rng: BoxRng, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + maybe_authorization: Option>>, + input: Option>, +) -> Result { + let MatrixJsonBody(input) = input.unwrap_or_default(); + let TypedHeader(authorization) = maybe_authorization.ok_or(RouteError::MissingAuthorization)?; + + let token = authorization.token(); + let token_type = TokenType::check(token)?; + + if token_type != TokenType::CompatAccessToken { + return Err(RouteError::NotACompatToken); + } + + let token = repo + .compat_access_token() + .find_by_token(token) + .await? + .ok_or(RouteError::NotACompatToken)?; + + if !token.is_valid(clock.now()) { + return Err(RouteError::InvalidToken(token.id)); + } + + let session = repo + .compat_session() + .lookup(token.session_id) + .await? + .ok_or(RouteError::CantLoadSession(token.session_id))?; + + if !session.is_valid() { + return Err(RouteError::InvalidSession(session.id)); + } + + activity_tracker + .record_compat_session(&clock, &session) + .await; + + let user = repo + .user() + .lookup(session.user_id) + .await? + .ok_or(RouteError::CantLoadUser(session.user_id))?; + + if !user.is_valid() { + return Err(RouteError::InvalidUser(session.user_id)); + } + + if !input.only_compat_is_fine { + return Err(RouteError::NotSupported); + } + + let filter = CompatSessionFilter::new().for_user(&user).active_only(); + let affected_sessions = repo.compat_session().finish_bulk(&clock, filter).await?; + info!( + "Logged out {affected_sessions} sessions for user {user_id}", + user_id = user.id + ); + + // Schedule a job to sync the devices of the user with the homeserver + repo.queue_job() + .schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user)) + .await?; + + repo.save().await?; + + LOGOUT_ALL_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]); + + Ok(Json(serde_json::json!({}))) +} diff --git a/crates/handlers/src/compat/mod.rs b/crates/handlers/src/compat/mod.rs index fcb45d68c..abf02a28c 100644 --- a/crates/handlers/src/compat/mod.rs +++ b/crates/handlers/src/compat/mod.rs @@ -1,4 +1,4 @@ -// 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 @@ -14,7 +14,7 @@ use axum::{ response::IntoResponse, }; use hyper::{StatusCode, header}; -use mas_axum_utils::sentry::SentryEventID; +use mas_axum_utils::record_error; use serde::{Serialize, de::DeserializeOwned}; use thiserror::Error; @@ -22,6 +22,7 @@ pub(crate) mod login; pub(crate) mod login_sso_complete; pub(crate) mod login_sso_redirect; pub(crate) mod logout; +pub(crate) mod logout_all; pub(crate) mod refresh; #[derive(Debug, Serialize)] @@ -59,7 +60,7 @@ pub enum MatrixJsonBodyRejection { impl IntoResponse for MatrixJsonBodyRejection { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, !); let response = match self { Self::InvalidContentType | Self::ContentTypeNotJson(_) => MatrixError { errcode: "M_NOT_JSON", @@ -102,7 +103,7 @@ impl IntoResponse for MatrixJsonBodyRejection { }, }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -140,3 +141,29 @@ where Ok(Self(value)) } } + +impl axum::extract::OptionalFromRequest for MatrixJsonBody +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = MatrixJsonBodyRejection; + + async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { + if req.headers().contains_key(header::CONTENT_TYPE) { + // If there is a Content-Type header, handle it as normal + let result = >::from_request(req, state).await?; + return Ok(Some(result)); + } + + // Else, we poke at the body, and deserialize it only if it's JSON + let bytes = >::from_request(req, state).await?; + if bytes.is_empty() { + return Ok(None); + } + + let value: T = serde_json::from_slice(&bytes)?; + + Ok(Some(Self(value))) + } +} diff --git a/crates/handlers/src/compat/refresh.rs b/crates/handlers/src/compat/refresh.rs index 9e3675910..7511e0e99 100644 --- a/crates/handlers/src/compat/refresh.rs +++ b/crates/handlers/src/compat/refresh.rs @@ -7,7 +7,7 @@ use axum::{Json, extract::State, response::IntoResponse}; use chrono::Duration; use hyper::StatusCode; -use mas_axum_utils::sentry::SentryEventID; +use mas_axum_utils::record_error; use mas_data_model::{SiteConfig, TokenFormatError, TokenType}; use mas_storage::{ BoxClock, BoxRepository, BoxRng, Clock, @@ -16,6 +16,7 @@ use mas_storage::{ use serde::{Deserialize, Serialize}; use serde_with::{DurationMilliSeconds, serde_as}; use thiserror::Error; +use ulid::Ulid; use super::MatrixError; use crate::{BoundActivityTracker, impl_from_error_for_route}; @@ -31,46 +32,50 @@ pub enum RouteError { Internal(Box), #[error("invalid token")] - InvalidToken, + InvalidToken(#[from] TokenFormatError), - #[error("refresh token already consumed")] - RefreshTokenConsumed, + #[error("unknown token")] + UnknownToken, - #[error("invalid session")] - InvalidSession, + #[error("invalid token type {0}, expected a compat refresh token")] + InvalidTokenType(TokenType), - #[error("unknown session")] - UnknownSession, + #[error("refresh token already consumed {0}")] + RefreshTokenConsumed(Ulid), + + #[error("invalid compat session {0}")] + InvalidSession(Ulid), + + #[error("unknown comapt session {0}")] + UnknownSession(Ulid), } impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_) | Self::UnknownSession(_)); let response = match self { - Self::Internal(_) | Self::UnknownSession => MatrixError { + Self::Internal(_) | Self::UnknownSession(_) => MatrixError { errcode: "M_UNKNOWN", error: "Internal error", status: StatusCode::INTERNAL_SERVER_ERROR, }, - Self::InvalidToken | Self::InvalidSession | Self::RefreshTokenConsumed => MatrixError { + Self::InvalidToken(_) + | Self::UnknownToken + | Self::InvalidTokenType(_) + | Self::InvalidSession(_) + | Self::RefreshTokenConsumed(_) => MatrixError { errcode: "M_UNKNOWN_TOKEN", error: "Invalid refresh token", status: StatusCode::UNAUTHORIZED, }, }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } impl_from_error_for_route!(mas_storage::RepositoryError); -impl From for RouteError { - fn from(_e: TokenFormatError) -> Self { - Self::InvalidToken - } -} - #[serde_as] #[derive(Debug, Serialize)] pub struct ResponseBody { @@ -80,7 +85,7 @@ pub struct ResponseBody { expires_in_ms: Duration, } -#[tracing::instrument(name = "handlers.compat.refresh.post", skip_all, err)] +#[tracing::instrument(name = "handlers.compat.refresh.post", skip_all)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, @@ -92,27 +97,27 @@ pub(crate) async fn post( let token_type = TokenType::check(&input.refresh_token)?; if token_type != TokenType::CompatRefreshToken { - return Err(RouteError::InvalidToken); + return Err(RouteError::InvalidTokenType(token_type)); } let refresh_token = repo .compat_refresh_token() .find_by_token(&input.refresh_token) .await? - .ok_or(RouteError::InvalidToken)?; + .ok_or(RouteError::UnknownToken)?; if !refresh_token.is_valid() { - return Err(RouteError::RefreshTokenConsumed); + return Err(RouteError::RefreshTokenConsumed(refresh_token.id)); } let session = repo .compat_session() .lookup(refresh_token.session_id) .await? - .ok_or(RouteError::UnknownSession)?; + .ok_or(RouteError::UnknownSession(refresh_token.session_id))?; if !session.is_valid() { - return Err(RouteError::InvalidSession); + return Err(RouteError::InvalidSession(refresh_token.session_id)); } activity_tracker diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 4c854ed21..cfedd69e9 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -26,18 +26,18 @@ use futures_util::TryStreamExt; use headers::{Authorization, ContentType, HeaderValue, authorization::Bearer}; use hyper::header::CACHE_CONTROL; use mas_axum_utils::{ - FancyError, SessionInfo, SessionInfoExt, cookies::CookieJar, sentry::SentryEventID, + InternalError, SessionInfo, SessionInfoExt, cookies::CookieJar, sentry::SentryEventID, }; use mas_data_model::{BrowserSession, Session, SiteConfig, User}; use mas_matrix::HomeserverConnection; use mas_policy::{InstantiateError, Policy, PolicyFactory}; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, BoxRng, Clock, RepositoryError, SystemClock}; -use mas_storage_pg::PgRepository; +use mas_storage::{ + BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, Clock, RepositoryError, SystemClock, +}; use opentelemetry_semantic_conventions::trace::{GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME}; use rand::{SeedableRng, thread_rng}; use rand_chacha::ChaChaRng; -use sqlx::PgPool; use state::has_session_ended; use tracing::{Instrument, info_span}; use ulid::Ulid; @@ -69,7 +69,7 @@ pub struct ExtraRouterParameters { } struct GraphQLState { - pool: PgPool, + repository_factory: BoxRepositoryFactory, homeserver_connection: Arc, policy_factory: Arc, site_config: SiteConfig, @@ -81,11 +81,7 @@ struct GraphQLState { #[async_trait::async_trait] impl state::State for GraphQLState { async fn repository(&self) -> Result { - let repo = PgRepository::from_pool(&self.pool) - .await - .map_err(RepositoryError::from_error)?; - - Ok(repo.boxed()) + self.repository_factory.create().await } async fn policy(&self) -> Result { @@ -128,7 +124,7 @@ impl state::State for GraphQLState { #[must_use] pub fn schema( - pool: &PgPool, + repository_factory: BoxRepositoryFactory, policy_factory: &Arc, homeserver_connection: impl HomeserverConnection + 'static, site_config: SiteConfig, @@ -137,7 +133,7 @@ pub fn schema( limiter: Limiter, ) -> Schema { let state = GraphQLState { - pool: pool.clone(), + repository_factory, policy_factory: Arc::clone(policy_factory), homeserver_connection: Arc::new(homeserver_connection), site_config, @@ -383,7 +379,7 @@ pub async fn get( authorization: Option>>, user_agent: Option>, RawQuery(query): RawQuery, -) -> Result { +) -> Result { let token = authorization .as_ref() .map(|TypedHeader(Authorization(bearer))| bearer.token()); diff --git a/crates/handlers/src/graphql/model/browser_sessions.rs b/crates/handlers/src/graphql/model/browser_sessions.rs index 15046ebb5..5e15644e2 100644 --- a/crates/handlers/src/graphql/model/browser_sessions.rs +++ b/crates/handlers/src/graphql/model/browser_sessions.rs @@ -81,7 +81,11 @@ impl BrowserSession { /// The user-agent with which the session was created. pub async fn user_agent(&self) -> Option { - self.0.user_agent.clone().map(UserAgent::from) + self.0 + .user_agent + .clone() + .map(mas_data_model::UserAgent::parse) + .map(UserAgent::from) } /// The last IP address used by the session. diff --git a/crates/handlers/src/graphql/model/compat_sessions.rs b/crates/handlers/src/graphql/model/compat_sessions.rs index 1ac53a558..90adb61fe 100644 --- a/crates/handlers/src/graphql/model/compat_sessions.rs +++ b/crates/handlers/src/graphql/model/compat_sessions.rs @@ -98,7 +98,11 @@ impl CompatSession { /// The user-agent with which the session was created. pub async fn user_agent(&self) -> Option { - self.session.user_agent.clone().map(UserAgent::from) + self.session + .user_agent + .clone() + .map(mas_data_model::UserAgent::parse) + .map(UserAgent::from) } /// The associated SSO login, if any. @@ -161,6 +165,11 @@ impl CompatSession { pub async fn last_active_at(&self) -> Option> { self.session.last_active_at } + + /// A human-provided name for the session. + pub async fn human_name(&self) -> Option<&str> { + self.session.human_name.as_deref() + } } /// A compat SSO login represents a login done through the legacy Matrix login diff --git a/crates/handlers/src/graphql/model/oauth.rs b/crates/handlers/src/graphql/model/oauth.rs index fec318eb8..9ec94c288 100644 --- a/crates/handlers/src/graphql/model/oauth.rs +++ b/crates/handlers/src/graphql/model/oauth.rs @@ -61,7 +61,11 @@ impl OAuth2Session { /// The user-agent with which the session was created. pub async fn user_agent(&self) -> Option { - self.0.user_agent.clone().map(UserAgent::from) + self.0 + .user_agent + .clone() + .map(mas_data_model::UserAgent::parse) + .map(UserAgent::from) } /// The state of the session. @@ -124,6 +128,11 @@ impl OAuth2Session { pub async fn last_active_at(&self) -> Option> { self.0.last_active_at } + + /// The user-provided name for this session. + pub async fn human_name(&self) -> Option<&str> { + self.0.human_name.as_deref() + } } /// The application type advertised by the client. diff --git a/crates/handlers/src/graphql/mutations/compat_session.rs b/crates/handlers/src/graphql/mutations/compat_session.rs index 48bf23f81..3930b5670 100644 --- a/crates/handlers/src/graphql/mutations/compat_session.rs +++ b/crates/handlers/src/graphql/mutations/compat_session.rs @@ -64,6 +64,54 @@ impl EndCompatSessionPayload { } } +/// The input of the `setCompatSessionName` mutation. +#[derive(InputObject)] +pub struct SetCompatSessionNameInput { + /// The ID of the session to set the name of. + compat_session_id: ID, + + /// The new name of the session. + human_name: String, +} + +/// The payload of the `setCompatSessionName` mutation. +pub enum SetCompatSessionNamePayload { + /// The session was not found. + NotFound, + + /// The session was updated. + Updated(mas_data_model::CompatSession), +} + +/// The status of the `setCompatSessionName` mutation. +#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)] +enum SetCompatSessionNameStatus { + /// The session was updated. + Updated, + + /// The session was not found. + NotFound, +} + +#[Object] +impl SetCompatSessionNamePayload { + /// The status of the mutation. + async fn status(&self) -> SetCompatSessionNameStatus { + match self { + Self::Updated(_) => SetCompatSessionNameStatus::Updated, + Self::NotFound => SetCompatSessionNameStatus::NotFound, + } + } + + /// The session that was updated. + async fn oauth2_session(&self) -> Option { + match self { + Self::Updated(session) => Some(CompatSession::new(session.clone())), + Self::NotFound => None, + } + } +} + #[Object] impl CompatSessionMutations { async fn end_compat_session( @@ -105,4 +153,50 @@ impl CompatSessionMutations { Ok(EndCompatSessionPayload::Ended(Box::new(session))) } + + async fn set_compat_session_name( + &self, + ctx: &Context<'_>, + input: SetCompatSessionNameInput, + ) -> Result { + let state = ctx.state(); + let compat_session_id = NodeType::CompatSession.extract_ulid(&input.compat_session_id)?; + let requester = ctx.requester(); + + let mut repo = state.repository().await?; + let homeserver = state.homeserver_connection(); + + let session = repo.compat_session().lookup(compat_session_id).await?; + let Some(session) = session else { + return Ok(SetCompatSessionNamePayload::NotFound); + }; + + if !requester.is_owner_or_admin(&session) { + return Ok(SetCompatSessionNamePayload::NotFound); + } + + let user = repo + .user() + .lookup(session.user_id) + .await? + .context("User not found")?; + + let session = repo + .compat_session() + .set_human_name(session, Some(input.human_name.clone())) + .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) + .await + .context("Failed to provision device")?; + } + + repo.save().await?; + + Ok(SetCompatSessionNamePayload::Updated(session)) + } } diff --git a/crates/handlers/src/graphql/mutations/oauth2_session.rs b/crates/handlers/src/graphql/mutations/oauth2_session.rs index 4278d20a2..1d0282014 100644 --- a/crates/handlers/src/graphql/mutations/oauth2_session.rs +++ b/crates/handlers/src/graphql/mutations/oauth2_session.rs @@ -110,6 +110,54 @@ impl EndOAuth2SessionPayload { } } +/// The input of the `setOauth2SessionName` mutation. +#[derive(InputObject)] +pub struct SetOAuth2SessionNameInput { + /// The ID of the session to set the name of. + oauth2_session_id: ID, + + /// The new name of the session. + human_name: String, +} + +/// The payload of the `setOauth2SessionName` mutation. +pub enum SetOAuth2SessionNamePayload { + /// The session was not found. + NotFound, + + /// The session was updated. + Updated(mas_data_model::Session), +} + +/// The status of the `setOauth2SessionName` mutation. +#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)] +enum SetOAuth2SessionNameStatus { + /// The session was updated. + Updated, + + /// The session was not found. + NotFound, +} + +#[Object] +impl SetOAuth2SessionNamePayload { + /// The status of the mutation. + async fn status(&self) -> SetOAuth2SessionNameStatus { + match self { + Self::Updated(_) => SetOAuth2SessionNameStatus::Updated, + Self::NotFound => SetOAuth2SessionNameStatus::NotFound, + } + } + + /// The session that was updated. + async fn oauth2_session(&self) -> Option { + match self { + Self::Updated(session) => Some(OAuth2Session(session.clone())), + Self::NotFound => None, + } + } +} + #[Object] impl OAuth2SessionMutations { /// Create a new arbitrary OAuth 2.0 Session. @@ -168,7 +216,7 @@ impl OAuth2SessionMutations { for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .context("Failed to provision device")?; } @@ -247,4 +295,54 @@ impl OAuth2SessionMutations { Ok(EndOAuth2SessionPayload::Ended(session)) } + + async fn set_oauth2_session_name( + &self, + ctx: &Context<'_>, + input: SetOAuth2SessionNameInput, + ) -> Result { + let state = ctx.state(); + let oauth2_session_id = NodeType::OAuth2Session.extract_ulid(&input.oauth2_session_id)?; + let requester = ctx.requester(); + + let mut repo = state.repository().await?; + let homeserver = state.homeserver_connection(); + + let session = repo.oauth2_session().lookup(oauth2_session_id).await?; + let Some(session) = session else { + return Ok(SetOAuth2SessionNamePayload::NotFound); + }; + + if !requester.is_owner_or_admin(&session) { + return Ok(SetOAuth2SessionNamePayload::NotFound); + } + + let user_id = session.user_id.context("Session has no user")?; + + let user = repo + .user() + .lookup(user_id) + .await? + .context("User not found")?; + + let session = repo + .oauth2_session() + .set_human_name(session, Some(input.human_name.clone())) + .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) + .await + .context("Failed to provision device")?; + } + } + + repo.save().await?; + + Ok(SetOAuth2SessionNamePayload::Updated(session)) + } } diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index ec9d2afe0..301307d96 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -552,7 +552,7 @@ impl UserMutations { let user = repo.user().lock(&state.clock(), user).await?; if deactivate { - info!("Scheduling deactivation of user {}", user.id); + info!(%user.id, "Scheduling deactivation of user"); repo.queue_job() .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, deactivate)) .await?; diff --git a/crates/handlers/src/health.rs b/crates/handlers/src/health.rs index f8a2672d7..b1dfbf993 100644 --- a/crates/handlers/src/health.rs +++ b/crates/handlers/src/health.rs @@ -5,11 +5,11 @@ // Please see LICENSE in the repository root for full details. use axum::{extract::State, response::IntoResponse}; -use mas_axum_utils::FancyError; +use mas_axum_utils::InternalError; use sqlx::PgPool; use tracing::{Instrument, info_span}; -pub async fn get(State(pool): State) -> Result { +pub async fn get(State(pool): State) -> Result { let mut conn = pool.acquire().await?; sqlx::query("SELECT $1") diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 4d610482f..4b3a842c8 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -35,14 +35,14 @@ use hyper::{ ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE, }, }; -use mas_axum_utils::{FancyError, cookies::CookieJar}; +use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_data_model::SiteConfig; use mas_http::CorsLayerExt; use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::{Route, UrlBuilder}; -use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_storage::{BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng}; use mas_templates::{ErrorContext, NotFoundContext, TemplateContext, Templates}; use opentelemetry::metrics::Meter; use sqlx::PgPool; @@ -203,6 +203,7 @@ where Encrypter: FromRef, reqwest::Client: FromRef, SiteConfig: FromRef, + Templates: FromRef, Arc: FromRef, BoxClock: FromRequestParts, BoxRng: FromRequestParts, @@ -248,6 +249,8 @@ where ACCEPT_LANGUAGE, CONTENT_LANGUAGE, CONTENT_TYPE, + // Swagger will send this header, so we have to allow it to avoid CORS errors + HeaderName::from_static("x-requested-with"), ]) .max_age(Duration::from_secs(60 * 60)), ) @@ -262,6 +265,7 @@ where Arc: FromRef, PasswordManager: FromRef, Limiter: FromRef, + BoxRepositoryFactory: FromRef, BoundActivityTracker: FromRequestParts, RequesterFingerprint: FromRequestParts, BoxRepository: FromRequestParts, @@ -277,6 +281,10 @@ where mas_router::CompatLogout::route(), post(self::compat::logout::post), ) + .route( + mas_router::CompatLogoutAll::route(), + post(self::compat::logout_all::post), + ) .route( mas_router::CompatRefresh::route(), post(self::compat::refresh::post), @@ -437,16 +445,14 @@ where ) .layer(AndThenLayer::new( async move |response: axum::response::Response| { - if response.status().is_server_error() { - // 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()); - } + // 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()); } } @@ -466,7 +472,7 @@ pub async fn fallback( method: Method, version: Version, PreferredLanguage(locale): PreferredLanguage, -) -> Result { +) -> Result { let ctx = NotFoundContext::new(&method, version, &uri).with_language(locale); // XXX: this should look at the Accept header and return JSON if requested diff --git a/crates/handlers/src/oauth2/authorization/consent.rs b/crates/handlers/src/oauth2/authorization/consent.rs index 273542383..14dfd0e7f 100644 --- a/crates/handlers/src/oauth2/authorization/consent.rs +++ b/crates/handlers/src/oauth2/authorization/consent.rs @@ -13,7 +13,7 @@ use hyper::StatusCode; use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, - sentry::SentryEventID, + record_error, }; use mas_data_model::AuthorizationGrantStage; use mas_keystore::Keystore; @@ -46,11 +46,11 @@ pub enum RouteError { #[error("Authorization grant not found")] GrantNotFound, - #[error("Authorization grant already used")] - GrantNotPending, + #[error("Authorization grant {0} already used")] + GrantNotPending(Ulid), - #[error("Failed to load client")] - NoSuchClient, + #[error("Failed to load client {0}")] + NoSuchClient(Ulid), } impl_from_error_for_route!(mas_templates::TemplateError); @@ -64,10 +64,10 @@ impl_from_error_for_route!(super::callback::CallbackDestinationError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_) | Self::NoSuchClient(_)); ( StatusCode::INTERNAL_SERVER_ERROR, - SentryEventID::from(event_id), + sentry_event_id, self.to_string(), ) .into_response() @@ -78,7 +78,6 @@ impl IntoResponse for RouteError { name = "handlers.oauth2.authorization.consent.get", fields(grant.id = %grant_id), skip_all, - err, )] pub(crate) async fn get( mut rng: BoxRng, @@ -118,10 +117,10 @@ pub(crate) async fn get( .oauth2_client() .lookup(grant.client_id) .await? - .ok_or(RouteError::NoSuchClient)?; + .ok_or(RouteError::NoSuchClient(grant.client_id))?; if !matches!(grant.stage, AuthorizationGrantStage::Pending) { - return Err(RouteError::GrantNotPending); + return Err(RouteError::GrantNotPending(grant.id)); } let Some(session) = maybe_session else { @@ -172,7 +171,6 @@ pub(crate) async fn get( name = "handlers.oauth2.authorization.consent.post", fields(grant.id = %grant_id), skip_all, - err, )] pub(crate) async fn post( mut rng: BoxRng, @@ -229,7 +227,11 @@ pub(crate) async fn post( .oauth2_client() .lookup(grant.client_id) .await? - .ok_or(RouteError::NoSuchClient)?; + .ok_or(RouteError::NoSuchClient(grant.client_id))?; + + if !matches!(grant.stage, AuthorizationGrantStage::Pending) { + return Err(RouteError::GrantNotPending(grant.id)); + } let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index 7e131f812..c3b080eae 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -9,7 +9,7 @@ use axum::{ response::{IntoResponse, Response}, }; use hyper::StatusCode; -use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, sentry::SentryEventID}; +use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, record_error}; use mas_data_model::{AuthorizationCode, Pkce}; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ @@ -53,7 +53,7 @@ pub enum RouteError { impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); // TODO: better error pages let response = match self { RouteError::Internal(e) => { @@ -75,7 +75,7 @@ impl IntoResponse for RouteError { .into_response(), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -122,7 +122,6 @@ fn resolve_response_mode( name = "handlers.oauth2.authorization.get", fields(client.id = %params.auth.client_id), skip_all, - err, )] #[allow(clippy::too_many_lines)] pub(crate) async fn get( @@ -275,6 +274,7 @@ pub(crate) async fn get( response_mode, response_type.has_id_token(), params.auth.login_hint, + Some(locale.to_string()), ) .await?; let continue_grant = PostAuthAction::continue_grant(grant.id); @@ -319,7 +319,7 @@ pub(crate) async fn get( let response = match res { Ok(r) => r, Err(err) => { - tracing::error!(%err); + tracing::error!(message = &err as &dyn std::error::Error); callback_destination.go( &templates, &locale, diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index e38bf17fd..1feec8c3e 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -11,9 +11,8 @@ use headers::{CacheControl, Pragma}; use hyper::StatusCode; use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, - sentry::SentryEventID, + record_error, }; -use mas_data_model::UserAgent; use mas_keystore::Encrypter; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng, oauth2::OAuth2DeviceCodeGrantParams}; @@ -24,6 +23,7 @@ use oauth2_types::{ }; use rand::distributions::{Alphanumeric, DistString}; use thiserror::Error; +use ulid::Ulid; use crate::{BoundActivityTracker, impl_from_error_for_route}; @@ -35,35 +35,46 @@ pub(crate) enum RouteError { #[error("client not found")] ClientNotFound, - #[error("client not allowed")] - ClientNotAllowed, + #[error("client {0} is not allowed to use the device code grant")] + ClientNotAllowed(Ulid), - #[error("could not verify client credentials")] - ClientCredentialsVerification(#[from] CredentialsVerificationError), + #[error("invalid client credentials for client {client_id}")] + InvalidClientCredentials { + client_id: Ulid, + #[source] + source: CredentialsVerificationError, + }, + + #[error("could not verify client credentials for client {client_id}")] + ClientCredentialsVerification { + client_id: Ulid, + #[source] + source: CredentialsVerificationError, + }, } impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); let response = match self { - Self::Internal(_) => ( + Self::Internal(_) | Self::ClientCredentialsVerification { .. } => ( StatusCode::INTERNAL_SERVER_ERROR, Json(ClientError::from(ClientErrorCode::ServerError)), ), - Self::ClientNotFound | Self::ClientCredentialsVerification(_) => ( + Self::ClientNotFound | Self::InvalidClientCredentials { .. } => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::InvalidClient)), ), - Self::ClientNotAllowed => ( + Self::ClientNotAllowed(_) => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::UnauthorizedClient)), ), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -71,7 +82,6 @@ impl IntoResponse for RouteError { name = "handlers.oauth2.device.request.post", fields(client.id = client_authorization.client_id()), skip_all, - err, )] pub(crate) async fn post( mut rng: BoxRng, @@ -94,15 +104,28 @@ pub(crate) async fn post( let method = client .token_endpoint_auth_method .as_ref() - .ok_or(RouteError::ClientNotAllowed)?; + .ok_or(RouteError::ClientNotAllowed(client.id))?; client_authorization .credentials .verify(&http_client, &encrypter, method, &client) - .await?; + .await + .map_err(|err| { + if err.is_internal() { + RouteError::ClientCredentialsVerification { + client_id: client.id, + source: err, + } + } else { + RouteError::InvalidClientCredentials { + client_id: client.id, + source: err, + } + } + })?; if !client.grant_types.contains(&GrantType::DeviceCode) { - return Err(RouteError::ClientNotAllowed); + return Err(RouteError::ClientNotAllowed(client.id)); } let scope = client_authorization @@ -113,7 +136,7 @@ pub(crate) async fn post( let expires_in = Duration::microseconds(20 * 60 * 1000 * 1000); - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let ip_address = activity_tracker.ip(); let device_code = Alphanumeric.sample_string(&mut rng, 32); diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 3f46c7a38..05e1d502d 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -12,7 +12,7 @@ use axum::{ }; use axum_extra::TypedHeader; use mas_axum_utils::{ - FancyError, + InternalError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -41,6 +41,7 @@ pub(crate) struct ConsentForm { action: Action, } +#[tracing::instrument(name = "handlers.oauth2.device.consent.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, @@ -53,7 +54,7 @@ pub(crate) async fn get( user_agent: Option>, cookie_jar: CookieJar, Path(grant_id): Path, -) -> Result { +) -> Result { let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -85,17 +86,21 @@ pub(crate) async fn get( .oauth2_device_code_grant() .lookup(grant_id) .await? - .context("Device grant not found")?; + .context("Device grant not found") + .map_err(InternalError::from_anyhow)?; if grant.expires_at < clock.now() { - return Err(FancyError::from(anyhow::anyhow!("Grant is expired"))); + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Grant is expired" + ))); } let client = repo .oauth2_client() .lookup(grant.client_id) .await? - .context("Client not found")?; + .context("Client not found") + .map_err(InternalError::from_anyhow)?; // Evaluate the policy let res = policy @@ -131,11 +136,13 @@ pub(crate) async fn get( let rendered = templates .render_device_consent(&ctx) - .context("Failed to render template")?; + .context("Failed to render template") + .map_err(InternalError::from_anyhow)?; Ok((cookie_jar, Html(rendered)).into_response()) } +#[tracing::instrument(name = "handlers.oauth2.device.consent.post", skip_all)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, @@ -149,7 +156,7 @@ pub(crate) async fn post( cookie_jar: CookieJar, Path(grant_id): Path, Form(form): Form>, -) -> Result { +) -> Result { let form = cookie_jar.verify_form(&clock, form)?; let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, @@ -181,17 +188,21 @@ pub(crate) async fn post( .oauth2_device_code_grant() .lookup(grant_id) .await? - .context("Device grant not found")?; + .context("Device grant not found") + .map_err(InternalError::from_anyhow)?; if grant.expires_at < clock.now() { - return Err(FancyError::from(anyhow::anyhow!("Grant is expired"))); + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Grant is expired" + ))); } let client = repo .oauth2_client() .lookup(grant.client_id) .await? - .context("Client not found")?; + .context("Client not found") + .map_err(InternalError::from_anyhow)?; // Evaluate the policy let res = policy @@ -254,7 +265,8 @@ pub(crate) async fn post( let rendered = templates .render_device_consent(&ctx) - .context("Failed to render template")?; + .context("Failed to render template") + .map_err(InternalError::from_anyhow)?; Ok((cookie_jar, Html(rendered)).into_response()) } diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index 3e734cf1c..0e3c8bd2c 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Query, State}, response::{Html, IntoResponse}, }; -use mas_axum_utils::{FancyError, cookies::CookieJar}; +use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository}; use mas_templates::{ @@ -24,7 +24,7 @@ pub struct Params { code: Option, } -#[tracing::instrument(name = "handlers.oauth2.device.link.get", skip_all, err)] +#[tracing::instrument(name = "handlers.oauth2.device.link.get", skip_all)] pub(crate) async fn get( clock: BoxClock, mut repo: BoxRepository, @@ -33,7 +33,7 @@ pub(crate) async fn get( State(url_builder): State, cookie_jar: CookieJar, Query(query): Query, -) -> Result { +) -> Result { let mut form_state = FormState::from_form(&query); // If we have a code in query, find it in the database diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index a97c62ab6..9b3e50e7c 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -10,7 +10,7 @@ use axum::{Json, extract::State, http::HeaderValue, response::IntoResponse}; use hyper::{HeaderMap, StatusCode}; use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, - sentry::SentryEventID, + record_error, }; use mas_data_model::{Device, TokenFormatError, TokenType}; use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint}; @@ -28,6 +28,7 @@ use oauth2_types::{ }; use opentelemetry::{Key, KeyValue, metrics::Counter}; use thiserror::Error; +use ulid::Ulid; use crate::{ActivityTracker, METER, impl_from_error_for_route}; @@ -53,8 +54,8 @@ pub enum RouteError { ClientNotFound, /// The client is not allowed to introspect. - #[error("client is not allowed to introspect")] - NotAllowed, + #[error("client {0} is not allowed to introspect")] + NotAllowed(Ulid), /// The token type is not the one expected. #[error("unexpected token type")] @@ -73,30 +74,30 @@ pub enum RouteError { InvalidToken(TokenType), /// The OAuth session is not valid. - #[error("invalid oauth session")] - InvalidOAuthSession, + #[error("invalid oauth session {0}")] + InvalidOAuthSession(Ulid), /// The OAuth session could not be found in the database. - #[error("unknown oauth session")] - CantLoadOAuthSession, + #[error("unknown oauth session {0}")] + CantLoadOAuthSession(Ulid), /// The compat session is not valid. - #[error("invalid compat session")] - InvalidCompatSession, + #[error("invalid compat session {0}")] + InvalidCompatSession(Ulid), /// The compat session could not be found in the database. - #[error("unknown compat session")] - CantLoadCompatSession, + #[error("unknown compat session {0}")] + CantLoadCompatSession(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), - #[error("invalid user")] - InvalidUser, + #[error("invalid user {0}")] + InvalidUser(Ulid), - #[error("unknown user")] - CantLoadUser, + #[error("unknown user {0}")] + CantLoadUser(Ulid), #[error("bad request")] BadRequest, @@ -107,12 +108,19 @@ pub enum RouteError { impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!( + self, + Self::Internal(_) + | Self::CantLoadCompatSession(_) + | Self::CantLoadOAuthSession(_) + | Self::CantLoadUser(_) + ); + let response = match self { e @ (Self::Internal(_) - | Self::CantLoadCompatSession - | Self::CantLoadOAuthSession - | Self::CantLoadUser) => ( + | Self::CantLoadCompatSession(_) + | Self::CantLoadOAuthSession(_) + | Self::CantLoadUser(_)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json( ClientError::from(ClientErrorCode::ServerError).with_description(e.to_string()), @@ -136,9 +144,9 @@ impl IntoResponse for RouteError { Self::UnknownToken(_) | Self::UnexpectedTokenType | Self::InvalidToken(_) - | Self::InvalidUser - | Self::InvalidCompatSession - | Self::InvalidOAuthSession + | Self::InvalidUser(_) + | Self::InvalidCompatSession(_) + | Self::InvalidOAuthSession(_) | Self::InvalidTokenFormat(_) | Self::CantEncodeDeviceID(_) => { INTROSPECTION_COUNTER.add(1, &[KeyValue::new(ACTIVE.clone(), false)]); @@ -146,11 +154,12 @@ impl IntoResponse for RouteError { Json(INACTIVE).into_response() } - Self::NotAllowed => ( + Self::NotAllowed(_) => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::AccessDenied)), ) .into_response(), + Self::BadRequest => ( StatusCode::BAD_REQUEST, Json(ClientError::from(ClientErrorCode::InvalidRequest)), @@ -158,7 +167,7 @@ impl IntoResponse for RouteError { .into_response(), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -188,7 +197,6 @@ const SYNAPSE_ADMIN_SCOPE: ScopeToken = ScopeToken::from_static("urn:synapse:adm name = "handlers.oauth2.introspection.post", fields(client.id = client_authorization.client_id()), skip_all, - err, )] #[allow(clippy::too_many_lines)] pub(crate) async fn post( @@ -208,7 +216,7 @@ pub(crate) async fn post( let method = match &client.token_endpoint_auth_method { None | Some(OAuthClientAuthenticationMethod::None) => { - return Err(RouteError::NotAllowed); + return Err(RouteError::NotAllowed(client.id)); } Some(c) => c, }; @@ -259,10 +267,10 @@ pub(crate) async fn post( .oauth2_session() .lookup(access_token.session_id) .await? - .ok_or(RouteError::InvalidOAuthSession)?; + .ok_or(RouteError::CantLoadOAuthSession(access_token.session_id))?; if !session.is_valid() { - return Err(RouteError::InvalidOAuthSession); + return Err(RouteError::InvalidOAuthSession(session.id)); } // If this is the first time we're using this token, mark it as used @@ -280,10 +288,10 @@ pub(crate) async fn post( .user() .lookup(user_id) .await? - .ok_or(RouteError::CantLoadUser)?; + .ok_or(RouteError::CantLoadUser(user_id))?; if !user.is_valid() { - return Err(RouteError::InvalidUser); + return Err(RouteError::InvalidUser(user.id)); } (Some(user.sub), Some(user.username)) @@ -338,10 +346,10 @@ pub(crate) async fn post( .oauth2_session() .lookup(refresh_token.session_id) .await? - .ok_or(RouteError::CantLoadOAuthSession)?; + .ok_or(RouteError::CantLoadOAuthSession(refresh_token.session_id))?; if !session.is_valid() { - return Err(RouteError::InvalidOAuthSession); + return Err(RouteError::InvalidOAuthSession(session.id)); } // The session might not have a user on it (for Client Credentials grants for @@ -351,10 +359,10 @@ pub(crate) async fn post( .user() .lookup(user_id) .await? - .ok_or(RouteError::CantLoadUser)?; + .ok_or(RouteError::CantLoadUser(user_id))?; if !user.is_valid() { - return Err(RouteError::InvalidUser); + return Err(RouteError::InvalidUser(user.id)); } (Some(user.sub), Some(user.username)) @@ -407,20 +415,20 @@ pub(crate) async fn post( .compat_session() .lookup(access_token.session_id) .await? - .ok_or(RouteError::CantLoadCompatSession)?; + .ok_or(RouteError::CantLoadCompatSession(access_token.session_id))?; if !session.is_valid() { - return Err(RouteError::InvalidCompatSession); + return Err(RouteError::InvalidCompatSession(session.id)); } let user = repo .user() .lookup(session.user_id) .await? - .ok_or(RouteError::CantLoadUser)?; + .ok_or(RouteError::CantLoadUser(session.user_id))?; if !user.is_valid() { - return Err(RouteError::InvalidUser)?; + return Err(RouteError::InvalidUser(user.id))?; } // Grant the synapse admin scope if the session has the admin flag set. @@ -491,20 +499,20 @@ pub(crate) async fn post( .compat_session() .lookup(refresh_token.session_id) .await? - .ok_or(RouteError::CantLoadCompatSession)?; + .ok_or(RouteError::CantLoadCompatSession(refresh_token.session_id))?; if !session.is_valid() { - return Err(RouteError::InvalidCompatSession); + return Err(RouteError::InvalidCompatSession(session.id)); } let user = repo .user() .lookup(session.user_id) .await? - .ok_or(RouteError::CantLoadUser)?; + .ok_or(RouteError::CantLoadUser(session.user_id))?; if !user.is_valid() { - return Err(RouteError::InvalidUser)?; + return Err(RouteError::InvalidUser(user.id))?; } // Grant the synapse admin scope if the session has the admin flag set. diff --git a/crates/handlers/src/oauth2/registration.rs b/crates/handlers/src/oauth2/registration.rs index 0b1f3515d..f3f91d754 100644 --- a/crates/handlers/src/oauth2/registration.rs +++ b/crates/handlers/src/oauth2/registration.rs @@ -9,10 +9,10 @@ use std::sync::LazyLock; use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::TypedHeader; use hyper::StatusCode; -use mas_axum_utils::sentry::SentryEventID; +use mas_axum_utils::record_error; use mas_iana::oauth::OAuthClientAuthenticationMethod; use mas_keystore::Encrypter; -use mas_policy::{Policy, Violation}; +use mas_policy::{EvaluationResult, Policy}; use mas_storage::{BoxClock, BoxRepository, BoxRng, oauth2::OAuth2ClientRepository}; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, @@ -55,8 +55,8 @@ pub(crate) enum RouteError { #[error("{0} is a public suffix, not a valid domain")] UrlIsPublicSuffix(&'static str), - #[error("denied by the policy: {0:?}")] - PolicyDenied(Vec), + #[error("client registration denied by the policy: {0}")] + PolicyDenied(EvaluationResult), } impl_from_error_for_route!(mas_storage::RepositoryError); @@ -67,7 +67,7 @@ impl_from_error_for_route!(serde_json::Error); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); REGISTRATION_COUNTER.add(1, &[KeyValue::new(RESULT, "denied")]); @@ -143,15 +143,20 @@ impl IntoResponse for RouteError { // For policy violations, we return an `invalid_client_metadata` error with the details // of the violations in most cases. If a violation includes `redirect_uri` in the // message, we return an `invalid_redirect_uri` error instead. - Self::PolicyDenied(violations) => { + Self::PolicyDenied(evaluation) => { // TODO: detect them better - let code = if violations.iter().any(|v| v.msg.contains("redirect_uri")) { + let code = if evaluation + .violations + .iter() + .any(|v| v.msg.contains("redirect_uri")) + { ClientErrorCode::InvalidRedirectUri } else { ClientErrorCode::InvalidClientMetadata }; - let collected = &violations + let collected = &evaluation + .violations .iter() .map(|v| v.msg.clone()) .collect::>(); @@ -165,7 +170,7 @@ impl IntoResponse for RouteError { } }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -207,7 +212,7 @@ fn localised_url_has_public_suffix(url: &Localized) -> bool { url.iter().any(|(_lang, url)| host_is_public_suffix(url)) } -#[tracing::instrument(name = "handlers.oauth2.registration.post", skip_all, err)] +#[tracing::instrument(name = "handlers.oauth2.registration.post", skip_all)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, @@ -282,7 +287,7 @@ pub(crate) async fn post( }) .await?; if !res.valid() { - return Err(RouteError::PolicyDenied(res.violations)); + return Err(RouteError::PolicyDenied(res)); } let (client_secret, encrypted_client_secret) = match metadata.token_endpoint_auth_method { diff --git a/crates/handlers/src/oauth2/revoke.rs b/crates/handlers/src/oauth2/revoke.rs index bedd8fbf7..758f0d647 100644 --- a/crates/handlers/src/oauth2/revoke.rs +++ b/crates/handlers/src/oauth2/revoke.rs @@ -8,7 +8,7 @@ use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, - sentry::SentryEventID, + record_error, }; use mas_data_model::TokenType; use mas_iana::oauth::OAuthTokenTypeHint; @@ -22,6 +22,7 @@ use oauth2_types::{ requests::RevocationRequest, }; use thiserror::Error; +use ulid::Ulid; use crate::{BoundActivityTracker, impl_from_error_for_route}; @@ -39,8 +40,19 @@ pub(crate) enum RouteError { #[error("client not allowed")] ClientNotAllowed, - #[error("could not verify client credentials")] - ClientCredentialsVerification(#[from] CredentialsVerificationError), + #[error("invalid client credentials for client {client_id}")] + InvalidClientCredentials { + client_id: Ulid, + #[source] + source: CredentialsVerificationError, + }, + + #[error("could not verify client credentials for client {client_id}")] + ClientCredentialsVerification { + client_id: Ulid, + #[source] + source: CredentialsVerificationError, + }, #[error("client is unauthorized")] UnauthorizedClient, @@ -54,9 +66,9 @@ pub(crate) enum RouteError { impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); let response = match self { - Self::Internal(_) => ( + Self::Internal(_) | Self::ClientCredentialsVerification { .. } => ( StatusCode::INTERNAL_SERVER_ERROR, Json(ClientError::from(ClientErrorCode::ServerError)), ) @@ -68,7 +80,7 @@ impl IntoResponse for RouteError { ) .into_response(), - Self::ClientNotFound | Self::ClientCredentialsVerification(_) => ( + Self::ClientNotFound | Self::InvalidClientCredentials { .. } => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::InvalidClient)), ) @@ -90,7 +102,7 @@ impl IntoResponse for RouteError { Self::UnknownToken => StatusCode::OK.into_response(), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -106,7 +118,6 @@ impl From for RouteError { name = "handlers.oauth2.revoke.post", fields(client.id = client_authorization.client_id()), skip_all, - err, )] pub(crate) async fn post( clock: BoxClock, @@ -131,7 +142,20 @@ pub(crate) async fn post( client_authorization .credentials .verify(&http_client, &encrypter, method, &client) - .await?; + .await + .map_err(|err| { + if err.is_internal() { + RouteError::ClientCredentialsVerification { + client_id: client.id, + source: err, + } + } else { + RouteError::InvalidClientCredentials { + client_id: client.id, + source: err, + } + } + })?; let Some(form) = client_authorization.form else { return Err(RouteError::BadRequest); diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index d361bcc99..3c8c9db20 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -13,11 +13,12 @@ use headers::{CacheControl, HeaderMap, HeaderMapExt, Pragma}; use hyper::StatusCode; use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, - sentry::SentryEventID, + record_error, }; use mas_data_model::{ - AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, SiteConfig, TokenType, UserAgent, + AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, SiteConfig, TokenType, }; +use mas_i18n::DataLocale; use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_oidc_client::types::scope::ScopeToken; @@ -31,6 +32,7 @@ use mas_storage::{ }, user::BrowserSessionRepository, }; +use mas_templates::{DeviceNameContext, TemplateContext, Templates}; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, pkce::CodeChallengeError, @@ -42,7 +44,7 @@ use oauth2_types::{ }; use opentelemetry::{Key, KeyValue, metrics::Counter}; use thiserror::Error; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use ulid::Ulid; use super::{generate_id_token, generate_token_pair}; @@ -72,17 +74,28 @@ pub(crate) enum RouteError { #[error("client not found")] ClientNotFound, - #[error("client not allowed")] - ClientNotAllowed, + #[error("client not allowed to use the token endpoint: {0}")] + ClientNotAllowed(Ulid), - #[error("could not verify client credentials")] - ClientCredentialsVerification(#[from] CredentialsVerificationError), + #[error("invalid client credentials for client {client_id}")] + InvalidClientCredentials { + client_id: Ulid, + #[source] + source: CredentialsVerificationError, + }, + + #[error("could not verify client credentials for client {client_id}")] + ClientCredentialsVerification { + client_id: Ulid, + #[source] + source: CredentialsVerificationError, + }, #[error("grant not found")] GrantNotFound, - #[error("invalid grant")] - InvalidGrant, + #[error("invalid grant {0}")] + InvalidGrant(Ulid), #[error("refresh token not found")] RefreshTokenNotFound, @@ -96,20 +109,23 @@ pub(crate) enum RouteError { #[error("client id mismatch: expected {expected}, got {actual}")] ClientIDMismatch { expected: Ulid, actual: Ulid }, - #[error("policy denied the request")] - DeniedByPolicy(Vec), + #[error("policy denied the request: {0}")] + DeniedByPolicy(mas_policy::EvaluationResult), #[error("unsupported grant type")] UnsupportedGrantType, - #[error("unauthorized client")] - UnauthorizedClient, + #[error("client {0} is not authorized to use this grant type")] + UnauthorizedClient(Ulid), - #[error("failed to load browser session")] - NoSuchBrowserSession, + #[error("unexpected client {was} (expected {expected})")] + UnexptectedClient { was: Ulid, expected: Ulid }, - #[error("failed to load oauth session")] - NoSuchOAuthSession, + #[error("failed to load browser session {0}")] + NoSuchBrowserSession(Ulid), + + #[error("failed to load oauth session {0}")] + NoSuchOAuthSession(Ulid), #[error( "failed to load the next refresh token ({next:?}) from the previous one ({previous:?})" @@ -145,14 +161,25 @@ pub(crate) enum RouteError { impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!( + self, + Self::Internal(_) + | Self::ClientCredentialsVerification { .. } + | Self::NoSuchBrowserSession(_) + | Self::NoSuchOAuthSession(_) + | Self::ProvisionDeviceFailed(_) + | Self::NoSuchNextRefreshToken { .. } + | Self::NoSuchNextAccessToken { .. } + | Self::NoAccessTokenOnRefreshToken { .. } + ); TOKEN_REQUEST_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); let response = match self { Self::Internal(_) - | Self::NoSuchBrowserSession - | Self::NoSuchOAuthSession + | Self::ClientCredentialsVerification { .. } + | Self::NoSuchBrowserSession(_) + | Self::NoSuchOAuthSession(_) | Self::ProvisionDeviceFailed(_) | Self::NoSuchNextRefreshToken { .. } | Self::NoSuchNextAccessToken { .. } @@ -160,10 +187,12 @@ impl IntoResponse for RouteError { StatusCode::INTERNAL_SERVER_ERROR, Json(ClientError::from(ClientErrorCode::ServerError)), ), + Self::BadRequest => ( StatusCode::BAD_REQUEST, Json(ClientError::from(ClientErrorCode::InvalidRequest)), ), + Self::PkceVerification(err) => ( StatusCode::BAD_REQUEST, Json( @@ -171,19 +200,25 @@ impl IntoResponse for RouteError { .with_description(format!("PKCE verification failed: {err}")), ), ), - Self::ClientNotFound | Self::ClientCredentialsVerification(_) => ( + + Self::ClientNotFound | Self::InvalidClientCredentials { .. } => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::InvalidClient)), ), - Self::ClientNotAllowed | Self::UnauthorizedClient => ( + + Self::ClientNotAllowed(_) + | Self::UnauthorizedClient(_) + | Self::UnexptectedClient { .. } => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::UnauthorizedClient)), ), - Self::DeniedByPolicy(violations) => ( + + Self::DeniedByPolicy(evaluation) => ( StatusCode::FORBIDDEN, Json( ClientError::from(ClientErrorCode::InvalidScope).with_description( - violations + evaluation + .violations .into_iter() .map(|violation| violation.msg) .collect::>() @@ -191,19 +226,23 @@ impl IntoResponse for RouteError { ), ), ), + Self::DeviceCodeRejected => ( StatusCode::FORBIDDEN, Json(ClientError::from(ClientErrorCode::AccessDenied)), ), + Self::DeviceCodeExpired => ( StatusCode::FORBIDDEN, Json(ClientError::from(ClientErrorCode::ExpiredToken)), ), + Self::DeviceCodePending => ( StatusCode::FORBIDDEN, Json(ClientError::from(ClientErrorCode::AuthorizationPending)), ), - Self::InvalidGrant + + Self::InvalidGrant(_) | Self::DeviceCodeExchanged | Self::RefreshTokenNotFound | Self::RefreshTokenInvalid(_) @@ -213,16 +252,19 @@ impl IntoResponse for RouteError { StatusCode::BAD_REQUEST, Json(ClientError::from(ClientErrorCode::InvalidGrant)), ), + Self::UnsupportedGrantType => ( StatusCode::BAD_REQUEST, Json(ClientError::from(ClientErrorCode::UnsupportedGrantType)), ), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } +impl_from_error_for_route!(mas_i18n::DataError); +impl_from_error_for_route!(mas_templates::TemplateError); impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_policy::EvaluationError); impl_from_error_for_route!(super::IdTokenSignatureError); @@ -231,7 +273,6 @@ impl_from_error_for_route!(super::IdTokenSignatureError); name = "handlers.oauth2.token.post", fields(client.id = client_authorization.client_id()), skip_all, - err, )] pub(crate) async fn post( mut rng: BoxRng, @@ -244,11 +285,12 @@ pub(crate) async fn post( State(homeserver): State>, State(site_config): State, State(encrypter): State, + State(templates): State, policy: Policy, user_agent: Option>, client_authorization: ClientAuthorization, ) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let client = client_authorization .credentials .fetch(&mut repo) @@ -258,12 +300,27 @@ pub(crate) async fn post( let method = client .token_endpoint_auth_method .as_ref() - .ok_or(RouteError::ClientNotAllowed)?; + .ok_or(RouteError::ClientNotAllowed(client.id))?; client_authorization .credentials .verify(&http_client, &encrypter, method, &client) - .await?; + .await + .map_err(|err| { + // Classify the error differntly, depending on whether it's an 'internal' error, + // or just because the client presented invalid credentials. + if err.is_internal() { + RouteError::ClientCredentialsVerification { + client_id: client.id, + source: err, + } + } else { + RouteError::InvalidClientCredentials { + client_id: client.id, + source: err, + } + } + })?; let form = client_authorization.form.ok_or(RouteError::BadRequest)?; @@ -282,6 +339,7 @@ pub(crate) async fn post( &site_config, repo, &homeserver, + &templates, user_agent, ) .await? @@ -363,11 +421,12 @@ async fn authorization_code_grant( site_config: &SiteConfig, mut repo: BoxRepository, homeserver: &Arc, - user_agent: Option, + templates: &Templates, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::AuthorizationCode) { - return Err(RouteError::UnauthorizedClient); + return Err(RouteError::UnauthorizedClient(client.id)); } let authz_grant = repo @@ -381,40 +440,43 @@ async fn authorization_code_grant( let session_id = match authz_grant.stage { AuthorizationGrantStage::Cancelled { cancelled_at } => { debug!(%cancelled_at, "Authorization grant was cancelled"); - return Err(RouteError::InvalidGrant); + return Err(RouteError::InvalidGrant(authz_grant.id)); } AuthorizationGrantStage::Exchanged { exchanged_at, fulfilled_at, session_id, } => { - debug!(%exchanged_at, %fulfilled_at, "Authorization code was already exchanged"); + warn!(%exchanged_at, %fulfilled_at, "Authorization code was already exchanged"); // Ending the session if the token was already exchanged more than 20s ago if now - exchanged_at > Duration::microseconds(20 * 1000 * 1000) { - debug!("Ending potentially compromised session"); + warn!(oauth_session.id = %session_id, "Ending potentially compromised session"); let session = repo .oauth2_session() .lookup(session_id) .await? - .ok_or(RouteError::NoSuchOAuthSession)?; + .ok_or(RouteError::NoSuchOAuthSession(session_id))?; + + //if !session.is_finished() { repo.oauth2_session().finish(clock, session).await?; repo.save().await?; + //} } - return Err(RouteError::InvalidGrant); + return Err(RouteError::InvalidGrant(authz_grant.id)); } AuthorizationGrantStage::Pending => { - debug!("Authorization grant has not been fulfilled yet"); - return Err(RouteError::InvalidGrant); + warn!("Authorization grant has not been fulfilled yet"); + return Err(RouteError::InvalidGrant(authz_grant.id)); } AuthorizationGrantStage::Fulfilled { session_id, fulfilled_at, } => { if now - fulfilled_at > Duration::microseconds(10 * 60 * 1000 * 1000) { - debug!("Code exchange took more than 10 minutes"); - return Err(RouteError::InvalidGrant); + warn!("Code exchange took more than 10 minutes"); + return Err(RouteError::InvalidGrant(authz_grant.id)); } session_id @@ -425,7 +487,12 @@ async fn authorization_code_grant( .oauth2_session() .lookup(session_id) .await? - .ok_or(RouteError::NoSuchOAuthSession)?; + .ok_or(RouteError::NoSuchOAuthSession(session_id))?; + + // Generate a device name + let lang: DataLocale = authz_grant.locale.as_deref().unwrap_or("en").parse()?; + let ctx = DeviceNameContext::new(client.clone(), user_agent.clone()).with_language(lang); + let device_name = templates.render_device_name(&ctx)?; if let Some(user_agent) = user_agent { session = repo @@ -435,10 +502,16 @@ async fn authorization_code_grant( } // This should never happen, since we looked up in the database using the code - let code = authz_grant.code.as_ref().ok_or(RouteError::InvalidGrant)?; + let code = authz_grant + .code + .as_ref() + .ok_or(RouteError::InvalidGrant(authz_grant.id))?; if client.id != session.client_id { - return Err(RouteError::UnauthorizedClient); + return Err(RouteError::UnexptectedClient { + was: client.id, + expected: session.client_id, + }); } match (code.pkce.as_ref(), grant.code_verifier.as_ref()) { @@ -453,14 +526,14 @@ async fn authorization_code_grant( let Some(user_session_id) = session.user_session_id else { tracing::warn!("No user session associated with this OAuth2 session"); - return Err(RouteError::InvalidGrant); + return Err(RouteError::InvalidGrant(authz_grant.id)); }; let browser_session = repo .browser_session() .lookup(user_session_id) .await? - .ok_or(RouteError::NoSuchBrowserSession)?; + .ok_or(RouteError::NoSuchBrowserSession(user_session_id))?; let last_authentication = repo .browser_session() @@ -506,7 +579,7 @@ async fn authorization_code_grant( for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), Some(&device_name)) .await .map_err(RouteError::ProvisionDeviceFailed)?; } @@ -535,11 +608,11 @@ async fn refresh_token_grant( client: &Client, site_config: &SiteConfig, mut repo: BoxRepository, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::RefreshToken) { - return Err(RouteError::UnauthorizedClient); + return Err(RouteError::UnauthorizedClient(client.id)); } let refresh_token = repo @@ -552,7 +625,7 @@ async fn refresh_token_grant( .oauth2_session() .lookup(refresh_token.session_id) .await? - .ok_or(RouteError::NoSuchOAuthSession)?; + .ok_or(RouteError::NoSuchOAuthSession(refresh_token.session_id))?; // Let's for now record the user agent on each refresh, that should be // responsive enough and not too much of a burden on the database. @@ -688,11 +761,11 @@ async fn client_credentials_grant( site_config: &SiteConfig, mut repo: BoxRepository, mut policy: Policy, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::ClientCredentials) { - return Err(RouteError::UnauthorizedClient); + return Err(RouteError::UnauthorizedClient(client.id)); } // Default to an empty scope if none is provided @@ -710,12 +783,12 @@ async fn client_credentials_grant( grant_type: mas_policy::GrantType::ClientCredentials, requester: mas_policy::Requester { ip_address: activity_tracker.ip(), - user_agent: user_agent.clone().map(|ua| ua.raw), + user_agent: user_agent.clone(), }, }) .await?; if !res.valid() { - return Err(RouteError::DeniedByPolicy(res.violations)); + return Err(RouteError::DeniedByPolicy(res)); } // Start the session @@ -767,11 +840,11 @@ async fn device_code_grant( site_config: &SiteConfig, mut repo: BoxRepository, homeserver: &Arc, - user_agent: Option, + user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type if !client.grant_types.contains(&GrantType::DeviceCode) { - return Err(RouteError::UnauthorizedClient); + return Err(RouteError::UnauthorizedClient(client.id)); } let grant = repo @@ -804,14 +877,14 @@ async fn device_code_grant( } DeviceCodeGrantState::Fulfilled { browser_session_id, .. - } => browser_session_id, + } => *browser_session_id, }; let browser_session = repo .browser_session() - .lookup(*browser_session_id) + .lookup(browser_session_id) .await? - .ok_or(RouteError::NoSuchBrowserSession)?; + .ok_or(RouteError::NoSuchBrowserSession(browser_session_id))?; // Start the session let mut session = repo @@ -882,7 +955,7 @@ async fn device_code_grant( for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .map_err(RouteError::ProvisionDeviceFailed)?; } @@ -981,6 +1054,7 @@ mod tests { ResponseMode::Query, false, None, + None, ) .await .unwrap(); @@ -1080,6 +1154,7 @@ mod tests { ResponseMode::Query, false, None, + None, ) .await .unwrap(); diff --git a/crates/handlers/src/oauth2/userinfo.rs b/crates/handlers/src/oauth2/userinfo.rs index 052892b33..064196e3d 100644 --- a/crates/handlers/src/oauth2/userinfo.rs +++ b/crates/handlers/src/oauth2/userinfo.rs @@ -12,7 +12,7 @@ use axum::{ use hyper::StatusCode; use mas_axum_utils::{ jwt::JwtResponse, - sentry::SentryEventID, + record_error, user_authorization::{AuthorizationVerificationError, UserAuthorization}, }; use mas_jose::{ @@ -25,6 +25,7 @@ use mas_storage::{BoxClock, BoxRepository, BoxRng, oauth2::OAuth2ClientRepositor use serde::Serialize; use serde_with::skip_serializing_none; use thiserror::Error; +use ulid::Ulid; use crate::{BoundActivityTracker, impl_from_error_for_route}; @@ -59,11 +60,11 @@ pub enum RouteError { #[error("no suitable key found for signing")] InvalidSigningKey, - #[error("failed to load client")] - NoSuchClient, + #[error("failed to load client {0}")] + NoSuchClient(Ulid), - #[error("failed to load user")] - NoSuchUser, + #[error("failed to load user {0}")] + NoSuchUser(Ulid), } impl_from_error_for_route!(mas_storage::RepositoryError); @@ -72,9 +73,18 @@ impl_from_error_for_route!(mas_jose::jwt::JwtSignatureError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + let sentry_event_id = record_error!( + self, + Self::Internal(_) + | Self::InvalidSigningKey + | Self::NoSuchClient(_) + | Self::NoSuchUser(_) + ); let response = match self { - Self::Internal(_) | Self::InvalidSigningKey | Self::NoSuchClient | Self::NoSuchUser => { + Self::Internal(_) + | Self::InvalidSigningKey + | Self::NoSuchClient(_) + | Self::NoSuchUser(_) => { (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() } Self::AuthorizationVerificationError(_) | Self::Unauthorized => { @@ -82,11 +92,11 @@ impl IntoResponse for RouteError { } }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } -#[tracing::instrument(name = "handlers.oauth2.userinfo.get", skip_all, err)] +#[tracing::instrument(name = "handlers.oauth2.userinfo.get", skip_all)] pub async fn get( mut rng: BoxRng, clock: BoxClock, @@ -116,7 +126,7 @@ pub async fn get( .user() .lookup(user_id) .await? - .ok_or(RouteError::NoSuchUser)?; + .ok_or(RouteError::NoSuchUser(user_id))?; let user_info = UserInfo { sub: user.sub.clone(), @@ -127,7 +137,7 @@ pub async fn get( .oauth2_client() .lookup(session.client_id) .await? - .ok_or(RouteError::NoSuchClient)?; + .ok_or(RouteError::NoSuchClient(session.client_id))?; repo.save().await?; diff --git a/crates/handlers/src/passwords.rs b/crates/handlers/src/passwords.rs index a0e36f8e5..eba028661 100644 --- a/crates/handlers/src/passwords.rs +++ b/crates/handlers/src/passwords.rs @@ -96,7 +96,10 @@ impl PasswordManager { /// # Errors /// /// Returns an error if the password manager is disabled - pub fn is_password_complex_enough(&self, password: &str) -> Result { + pub fn is_password_complex_enough( + &self, + password: &str, + ) -> Result { let inner = self.get_inner()?; let score = zxcvbn(password, &[]); Ok(u8::from(score.score()) >= inner.minimum_complexity) diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 03f36a214..e052f0557 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -34,8 +34,11 @@ 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, BoxRng, clock::MockClock}; -use mas_storage_pg::{DatabaseError, PgRepository}; +use mas_storage::{ + BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, RepositoryError, RepositoryFactory, + clock::MockClock, +}; +use mas_storage_pg::PgRepositoryFactory; use mas_templates::{SiteConfigExt, Templates}; use oauth2_types::{registration::ClientRegistrationResponse, requests::AccessTokenResponse}; use rand::SeedableRng; @@ -92,7 +95,7 @@ pub(crate) async fn policy_factory( #[derive(Clone)] pub(crate) struct TestState { - pub pool: PgPool, + pub repository_factory: PgRepositoryFactory, pub templates: Templates, pub key_store: Keystore, pub cookie_manager: CookieManager, @@ -210,7 +213,7 @@ impl TestState { let limiter = Limiter::new(&RateLimitingConfig::default()).unwrap(); let graphql_state = TestGraphQLState { - pool: pool.clone(), + repository_factory: PgRepositoryFactory::new(pool.clone()).boxed(), policy_factory: Arc::clone(&policy_factory), homeserver_connection: Arc::clone(&homeserver_connection), site_config: site_config.clone(), @@ -225,14 +228,14 @@ impl TestState { let graphql_schema = graphql::schema_builder().data(state).finish(); let activity_tracker = ActivityTracker::new( - pool.clone(), + PgRepositoryFactory::new(pool.clone()).boxed(), std::time::Duration::from_secs(60), &task_tracker, shutdown_token.child_token(), ); Ok(Self { - pool, + repository_factory: PgRepositoryFactory::new(pool), templates, key_store, cookie_manager, @@ -257,7 +260,7 @@ impl TestState { /// 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(); - let pool = self.pool.clone(); + let pool = self.repository_factory.pool(); let task_tracker = self.task_tracker.clone(); // This should trigger the cancellation drop guard @@ -352,9 +355,8 @@ impl TestState { access_token } - pub async fn repository(&self) -> Result { - let repo = PgRepository::from_pool(&self.pool).await?; - Ok(repo.boxed()) + pub async fn repository(&self) -> Result { + self.repository_factory.create().await } /// Returns a new random number generator. @@ -394,7 +396,7 @@ impl TestState { } struct TestGraphQLState { - pool: PgPool, + repository_factory: BoxRepositoryFactory, homeserver_connection: Arc, site_config: SiteConfig, policy_factory: Arc, @@ -408,11 +410,7 @@ struct TestGraphQLState { #[async_trait::async_trait] impl graphql::State for TestGraphQLState { async fn repository(&self) -> Result { - let repo = PgRepository::from_pool(&self.pool) - .await - .map_err(mas_storage::RepositoryError::from_error)?; - - Ok(repo.boxed()) + self.repository_factory.create().await } async fn policy(&self) -> Result { @@ -452,7 +450,13 @@ impl graphql::State for TestGraphQLState { impl FromRef for PgPool { fn from_ref(input: &TestState) -> Self { - input.pool.clone() + input.repository_factory.pool() + } +} + +impl FromRef for BoxRepositoryFactory { + fn from_ref(input: &TestState) -> Self { + input.repository_factory.clone().boxed() } } @@ -599,14 +603,14 @@ impl FromRequestParts for BoxRng { } impl FromRequestParts for BoxRepository { - type Rejection = ErrorWrapper; + type Rejection = ErrorWrapper; async fn from_request_parts( _parts: &mut axum::http::request::Parts, state: &TestState, ) -> Result { - let repo = PgRepository::from_pool(&state.pool).await?; - Ok(repo.boxed()) + let repo = state.repository_factory.create().await?; + Ok(repo) } } diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index ca41c236b..43403c137 100644 --- a/crates/handlers/src/upstream_oauth2/authorize.rs +++ b/crates/handlers/src/upstream_oauth2/authorize.rs @@ -9,10 +9,10 @@ use axum::{ response::{IntoResponse, Redirect}, }; use hyper::StatusCode; -use mas_axum_utils::{cookies::CookieJar, sentry::SentryEventID}; +use mas_axum_utils::{cookies::CookieJar, record_error}; use mas_data_model::UpstreamOAuthProvider; use mas_oidc_client::requests::authorization_code::AuthorizationRequestData; -use mas_router::UrlBuilder; +use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ BoxClock, BoxRepository, BoxRng, upstream_oauth2::{UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository}, @@ -41,13 +41,13 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + 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(), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -55,7 +55,6 @@ impl IntoResponse for RouteError { name = "handlers.upstream_oauth2.authorize.get", fields(upstream_oauth_provider.id = %provider_id), skip_all, - err, )] pub(crate) async fn get( mut rng: BoxRng, @@ -93,6 +92,21 @@ pub(crate) async fn get( data = data.with_response_mode(response_mode.into()); } + // 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); + } + } + } + let data = if let Some(methods) = lazy_metadata.pkce_methods().await? { data.with_code_challenge_methods_supported(methods) } else { diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 02a202745..6c1b7de63 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -6,6 +6,7 @@ use std::{collections::HashMap, sync::Arc}; +use mas_context::LogContext; use mas_data_model::{ UpstreamOAuthProvider, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, }; @@ -164,7 +165,7 @@ impl MetadataCache { /// /// This spawns a background task that will refresh the cache at the given /// interval. - #[tracing::instrument(name = "metadata_cache.warm_up_and_run", skip_all, err)] + #[tracing::instrument(name = "metadata_cache.warm_up_and_run", skip_all)] pub async fn warm_up_and_run( &self, client: &reqwest::Client, @@ -197,12 +198,14 @@ impl MetadataCache { loop { // Re-fetch the known metadata at the given interval tokio::time::sleep(interval).await; - cache.refresh_all(&client).await; + LogContext::new("metadata-cache-refresh") + .run(|| cache.refresh_all(&client)) + .await; } })) } - #[tracing::instrument(name = "metadata_cache.fetch", fields(%issuer), skip_all, err)] + #[tracing::instrument(name = "metadata_cache.fetch", fields(%issuer), skip_all)] async fn fetch( &self, client: &reqwest::Client, @@ -234,7 +237,7 @@ impl MetadataCache { } /// Get the metadata for the given issuer. - #[tracing::instrument(name = "metadata_cache.get", fields(%issuer), skip_all, err)] + #[tracing::instrument(name = "metadata_cache.get", fields(%issuer), skip_all)] pub async fn get( &self, client: &reqwest::Client, @@ -423,6 +426,7 @@ mod tests { disabled_at: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), additional_authorization_parameters: Vec::new(), + forward_login_hint: false, }; // 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 be4b5a2d1..75e8d63a0 100644 --- a/crates/handlers/src/upstream_oauth2/callback.rs +++ b/crates/handlers/src/upstream_oauth2/callback.rs @@ -13,7 +13,7 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use hyper::StatusCode; -use mas_axum_utils::{cookies::CookieJar, sentry::SentryEventID}; +use mas_axum_utils::{cookies::CookieJar, record_error}; use mas_data_model::{UpstreamOAuthProvider, UpstreamOAuthProviderResponseMode}; use mas_jose::claims::TokenHash; use mas_keystore::{Encrypter, Keystore}; @@ -153,7 +153,7 @@ impl_from_error_for_route!(super::cookie::UpstreamSessionNotFound); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); + 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(), @@ -161,7 +161,7 @@ impl IntoResponse for RouteError { e => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), }; - (SentryEventID::from(event_id), response).into_response() + (sentry_event_id, response).into_response() } } @@ -169,7 +169,6 @@ impl IntoResponse for RouteError { name = "handlers.upstream_oauth2.callback.handler", fields(upstream_oauth_provider.id = %provider_id), skip_all, - err, )] #[allow(clippy::too_many_lines, clippy::too_many_arguments)] pub(crate) async fn handler( @@ -357,10 +356,12 @@ pub(crate) async fn handler( ) .map_err(mas_oidc_client::error::IdTokenError::from)?; - // Nonce must match. - mas_jose::claims::NONCE - .extract_required_with_options(&mut claims, session.nonce.as_str()) - .map_err(mas_oidc_client::error::IdTokenError::from)?; + // Nonce must match if present. + if let Some(nonce) = session.nonce.as_deref() { + mas_jose::claims::NONCE + .extract_required_with_options(&mut claims, nonce) + .map_err(mas_oidc_client::error::IdTokenError::from)?; + } context = context.with_id_token_claims(claims); } diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index cacba650a..d95854faa 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -14,12 +14,11 @@ use axum::{ use axum_extra::typed_header::TypedHeader; use hyper::StatusCode; use mas_axum_utils::{ - FancyError, SessionInfoExt, + GenericError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, - sentry::SentryEventID, + record_error, }; -use mas_data_model::UserAgent; use mas_jose::jwt::Jwt; use mas_matrix::HomeserverConnection; use mas_policy::Policy; @@ -77,16 +76,16 @@ pub(crate) enum RouteError { LinkNotFound, /// Couldn't find the session on the link - #[error("Session not found")] - SessionNotFound, + #[error("Session {0} not found")] + SessionNotFound(Ulid), /// Couldn't find the user - #[error("User not found")] - UserNotFound, + #[error("User {0} not found")] + UserNotFound(Ulid), /// Couldn't find upstream provider - #[error("Upstream provider not found")] - ProviderNotFound, + #[error("Upstream provider {0} not found")] + ProviderNotFound(Ulid), /// Required attribute rendered to an empty string #[error("Template {template:?} rendered to an empty string")] @@ -104,8 +103,8 @@ pub(crate) enum RouteError { }, /// Session was already consumed - #[error("Session already consumed")] - SessionConsumed, + #[error("Session {0} already consumed")] + SessionConsumed(Ulid), #[error("Missing session cookie")] MissingCookie, @@ -129,14 +128,24 @@ impl_from_error_for_route!(mas_jose::jwt::JwtDecodeError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { - let event_id = sentry::capture_error(&self); - let response = match self { - Self::LinkNotFound => (StatusCode::NOT_FOUND, "Link not found").into_response(), - Self::Internal(e) => FancyError::from(e).into_response(), - e => FancyError::from(e).into_response(), + let sentry_event_id = record_error!( + self, + Self::Internal(_) + | Self::RequiredAttributeEmpty { .. } + | Self::RequiredAttributeRender { .. } + | Self::SessionNotFound(_) + | Self::ProviderNotFound(_) + | Self::UserNotFound(_) + | Self::HomeserverConnection(_) + ); + + let status_code = match self { + Self::LinkNotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, }; - (SentryEventID::from(event_id), response).into_response() + let response = GenericError::new(status_code, self); + (sentry_event_id, response).into_response() } } @@ -209,7 +218,6 @@ impl ToFormState for FormData { name = "handlers.upstream_oauth2.link.get", fields(upstream_oauth_link.id = %link_id), skip_all, - err, )] pub(crate) async fn get( mut rng: BoxRng, @@ -225,7 +233,7 @@ pub(crate) async fn get( user_agent: Option>, Path(link_id): Path, ) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); let (session_id, post_auth_action) = sessions_cookie .lookup_link(link_id) @@ -245,16 +253,16 @@ pub(crate) async fn get( .upstream_oauth_session() .lookup(session_id) .await? - .ok_or(RouteError::SessionNotFound)?; + .ok_or(RouteError::SessionNotFound(session_id))?; // This checks that we're in a browser session which is allowed to consume this // link: the upstream auth session should have been started in this browser. if upstream_session.link_id() != Some(link.id) { - return Err(RouteError::SessionNotFound); + return Err(RouteError::SessionNotFound(session_id)); } if upstream_session.is_consumed() { - return Err(RouteError::SessionConsumed); + return Err(RouteError::SessionConsumed(session_id)); } let (user_session_info, cookie_jar) = cookie_jar.session_info(); @@ -289,7 +297,7 @@ pub(crate) async fn get( .user() .lookup(user_id) .await? - .ok_or(RouteError::UserNotFound)?; + .ok_or(RouteError::UserNotFound(user_id))?; let ctx = UpstreamExistingLinkContext::new(user) .with_session(user_session) @@ -315,7 +323,7 @@ pub(crate) async fn get( .user() .lookup(user_id) .await? - .ok_or(RouteError::UserNotFound)?; + .ok_or(RouteError::UserNotFound(user_id))?; // Check that the user is not locked or deactivated if user.deactivated_at.is_some() { @@ -377,7 +385,7 @@ pub(crate) async fn get( .upstream_oauth_provider() .lookup(link.provider_id) .await? - .ok_or(RouteError::ProviderNotFound)?; + .ok_or(RouteError::ProviderNotFound(link.provider_id))?; let ctx = UpstreamRegister::new(link.clone(), provider.clone()); @@ -494,7 +502,7 @@ pub(crate) async fn get( email: None, requester: mas_policy::Requester { ip_address: activity_tracker.ip(), - user_agent: user_agent.clone().map(|ua| ua.raw), + user_agent: user_agent.clone(), }, }) .await?; @@ -543,7 +551,6 @@ pub(crate) async fn get( name = "handlers.upstream_oauth2.link.post", fields(upstream_oauth_link.id = %link_id), skip_all, - err, )] pub(crate) async fn post( mut rng: BoxRng, @@ -561,7 +568,7 @@ pub(crate) async fn post( Path(link_id): Path, Form(form): Form>, ) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let form = cookie_jar.verify_form(&clock, form)?; let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); @@ -583,16 +590,16 @@ pub(crate) async fn post( .upstream_oauth_session() .lookup(session_id) .await? - .ok_or(RouteError::SessionNotFound)?; + .ok_or(RouteError::SessionNotFound(session_id))?; // This checks that we're in a browser session which is allowed to consume this // link: the upstream auth session should have been started in this browser. if upstream_session.link_id() != Some(link.id) { - return Err(RouteError::SessionNotFound); + return Err(RouteError::SessionNotFound(session_id)); } if upstream_session.is_consumed() { - return Err(RouteError::SessionConsumed); + return Err(RouteError::SessionConsumed(session_id)); } let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); @@ -637,7 +644,7 @@ pub(crate) async fn post( .upstream_oauth_provider() .lookup(link.provider_id) .await? - .ok_or(RouteError::ProviderNotFound)?; + .ok_or(RouteError::ProviderNotFound(link.provider_id))?; // Let's try to import the claims from the ID token let env = environment(); @@ -779,7 +786,7 @@ pub(crate) async fn post( email: email.as_deref(), requester: mas_policy::Requester { ip_address: activity_tracker.ip(), - user_agent: user_agent.clone().map(|ua| ua.raw), + user_agent: user_agent.clone(), }, }) .await?; @@ -976,6 +983,7 @@ mod tests { pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, ui_order: 0, }, ) @@ -990,7 +998,7 @@ mod tests { &provider, "state".to_owned(), None, - "nonce".to_owned(), + None, ) .await .unwrap(); diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index d8010306f..47b657436 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Query, State}, response::{Html, IntoResponse}, }; -use mas_axum_utils::{FancyError, cookies::CookieJar}; +use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{AppContext, TemplateContext, Templates}; @@ -25,7 +25,7 @@ pub struct Params { action: Option, } -#[tracing::instrument(name = "handlers.views.app.get", skip_all, err)] +#[tracing::instrument(name = "handlers.views.app.get", skip_all)] pub async fn get( PreferredLanguage(locale): PreferredLanguage, State(templates): State, @@ -36,7 +36,7 @@ pub async fn get( clock: BoxClock, mut rng: BoxRng, cookie_jar: CookieJar, -) -> Result { +) -> Result { let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -74,12 +74,12 @@ pub async fn get( /// Like `get`, but allow anonymous access. /// Used for a subset of the account management paths. /// Needed for e.g. account recovery. -#[tracing::instrument(name = "handlers.views.app.get_anonymous", skip_all, err)] +#[tracing::instrument(name = "handlers.views.app.get_anonymous", skip_all)] pub async fn get_anonymous( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(url_builder): State, -) -> Result { +) -> Result { let ctx = AppContext::from_url_builder(&url_builder).with_language(locale); let content = templates.render_app(&ctx)?; diff --git a/crates/handlers/src/views/index.rs b/crates/handlers/src/views/index.rs index 8774b8528..c05f4e307 100644 --- a/crates/handlers/src/views/index.rs +++ b/crates/handlers/src/views/index.rs @@ -8,7 +8,7 @@ use axum::{ extract::State, response::{Html, IntoResponse, Response}, }; -use mas_axum_utils::{FancyError, cookies::CookieJar, csrf::CsrfExt}; +use mas_axum_utils::{InternalError, cookies::CookieJar, csrf::CsrfExt}; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{IndexContext, TemplateContext, Templates}; @@ -19,7 +19,7 @@ use crate::{ session::{SessionOrFallback, load_session_or_fallback}, }; -#[tracing::instrument(name = "handlers.views.index.get", skip_all, err)] +#[tracing::instrument(name = "handlers.views.index.get", skip_all)] pub async fn get( mut rng: BoxRng, clock: BoxClock, @@ -29,7 +29,7 @@ pub async fn get( mut repo: BoxRepository, cookie_jar: CookieJar, PreferredLanguage(locale): PreferredLanguage, -) -> Result { +) -> Result { let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 869e9a89d..87884ddba 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -13,11 +13,11 @@ use axum::{ use axum_extra::typed_header::TypedHeader; use hyper::StatusCode; use mas_axum_utils::{ - FancyError, SessionInfoExt, + InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{UserAgent, oauth2::LoginHint}; +use mas_data_model::oauth2::LoginHint; use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; @@ -61,7 +61,7 @@ impl ToFormState for LoginForm { type Field = LoginFormField; } -#[tracing::instrument(name = "handlers.views.login.get", skip_all, err)] +#[tracing::instrument(name = "handlers.views.login.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, @@ -74,7 +74,7 @@ pub(crate) async fn get( activity_tracker: BoundActivityTracker, Query(query): Query, cookie_jar: CookieJar, -) -> Result { +) -> Result { let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -127,7 +127,7 @@ pub(crate) async fn get( .await } -#[tracing::instrument(name = "handlers.views.login.post", skip_all, err)] +#[tracing::instrument(name = "handlers.views.login.post", skip_all)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, @@ -145,8 +145,8 @@ pub(crate) async fn post( cookie_jar: CookieJar, user_agent: Option>, Form(form): Form>, -) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); +) -> Result { + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); if !site_config.password_login_enabled { // XXX: is it necessary to have better errors here? return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); @@ -338,11 +338,11 @@ pub(crate) async fn post( Ok((cookie_jar, reply).into_response()) } -async fn get_user_by_email_or_by_username( +async fn get_user_by_email_or_by_username( site_config: SiteConfig, - repo: &mut impl RepositoryAccess, + repo: &mut R, username_or_email: &str, -) -> Result, Box> { +) -> Result, R::Error> { if site_config.login_with_email_allowed && username_or_email.contains('@') { let maybe_user_email = repo.user_email().find_by_email(username_or_email).await?; @@ -393,7 +393,7 @@ async fn render( rng: impl Rng, templates: &Templates, homeserver: &dyn HomeserverConnection, -) -> Result { +) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); let providers = repo.upstream_oauth_provider().all_enabled().await?; @@ -401,7 +401,10 @@ async fn render( .with_form_state(form_state) .with_upstream_providers(providers); - let next = action.load_context(repo).await?; + let next = action + .load_context(repo) + .await + .map_err(InternalError::from_anyhow)?; let ctx = if let Some(next) = next { let ctx = handle_login_hint(ctx, &next, homeserver); ctx.with_post_action(next) @@ -495,6 +498,7 @@ mod test { pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, ui_order: 0, }, ) @@ -536,6 +540,7 @@ mod test { pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, ui_order: 1, }, ) diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 5f717a5cf..66bd28311 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, }; use mas_axum_utils::{ - FancyError, SessionInfoExt, + InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -18,7 +18,7 @@ use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository}; use crate::BoundActivityTracker; -#[tracing::instrument(name = "handlers.views.logout.post", skip_all, err)] +#[tracing::instrument(name = "handlers.views.logout.post", skip_all)] pub(crate) async fn post( clock: BoxClock, mut repo: BoxRepository, @@ -26,7 +26,7 @@ pub(crate) async fn post( State(url_builder): State, activity_tracker: BoundActivityTracker, Form(form): Form>>, -) -> Result { +) -> Result { let form = cookie_jar.verify_form(&clock, form)?; let (session_info, cookie_jar) = cookie_jar.session_info(); diff --git a/crates/handlers/src/views/recovery/progress.rs b/crates/handlers/src/views/recovery/progress.rs index eaabef134..ea56e6cb1 100644 --- a/crates/handlers/src/views/recovery/progress.rs +++ b/crates/handlers/src/views/recovery/progress.rs @@ -11,7 +11,7 @@ use axum::{ }; use hyper::StatusCode; use mas_axum_utils::{ - FancyError, SessionInfoExt, + InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -36,7 +36,7 @@ pub(crate) async fn get( PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, Path(id): Path, -) -> Result { +) -> Result { if !site_config.account_recovery_allowed { let context = EmptyContext.with_language(locale); let rendered = templates.render_recovery_disabled(&context)?; @@ -90,7 +90,7 @@ pub(crate) async fn post( cookie_jar: CookieJar, Path(id): Path, Form(form): Form>, -) -> Result { +) -> Result { if !site_config.account_recovery_allowed { let context = EmptyContext.with_language(locale); let rendered = templates.render_recovery_disabled(&context)?; diff --git a/crates/handlers/src/views/recovery/start.rs b/crates/handlers/src/views/recovery/start.rs index 728e71834..72d0bc666 100644 --- a/crates/handlers/src/views/recovery/start.rs +++ b/crates/handlers/src/views/recovery/start.rs @@ -14,11 +14,11 @@ use axum::{ use axum_extra::typed_header::TypedHeader; use lettre::Address; use mas_axum_utils::{ - FancyError, SessionInfoExt, + InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{SiteConfig, UserAgent}; +use mas_data_model::SiteConfig; use mas_router::UrlBuilder; use mas_storage::{ BoxClock, BoxRepository, BoxRng, @@ -46,7 +46,7 @@ pub(crate) async fn get( State(url_builder): State, PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, -) -> Result { +) -> Result { if !site_config.account_recovery_allowed { let context = EmptyContext.with_language(locale); let rendered = templates.render_recovery_disabled(&context)?; @@ -86,7 +86,7 @@ pub(crate) async fn post( PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, Form(form): Form>, -) -> Result { +) -> Result { if !site_config.account_recovery_allowed { let context = EmptyContext.with_language(locale); let rendered = templates.render_recovery_disabled(&context)?; @@ -102,7 +102,7 @@ pub(crate) async fn post( return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); } - let user_agent = UserAgent::parse(user_agent.as_str().to_owned()); + let user_agent = user_agent.as_str().to_owned(); let ip_address = activity_tracker.ip(); let form = cookie_jar.verify_form(&clock, form)?; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 9afe22474..3afe24573 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -7,7 +7,7 @@ use axum::{ extract::{Query, State}, response::{Html, IntoResponse, Response}, }; -use mas_axum_utils::{FancyError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; +use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; use mas_data_model::SiteConfig; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, BoxRng}; @@ -20,7 +20,7 @@ mod cookie; pub(crate) mod password; pub(crate) mod steps; -#[tracing::instrument(name = "handlers.views.register.get", skip_all, err)] +#[tracing::instrument(name = "handlers.views.register.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, @@ -32,7 +32,7 @@ pub(crate) async fn get( activity_tracker: BoundActivityTracker, Query(query): Query, cookie_jar: CookieJar, -) -> Result { +) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (session_info, cookie_jar) = cookie_jar.session_info(); @@ -76,7 +76,10 @@ pub(crate) async fn get( } let mut ctx = RegisterContext::new(providers); - let post_action = query.load_context(&mut repo).await?; + let post_action = query + .load_context(&mut repo) + .await + .map_err(InternalError::from_anyhow)?; if let Some(action) = post_action { ctx = ctx.with_post_action(action); } diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index ee8ed7bdb..8c2925505 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -14,11 +14,11 @@ use axum_extra::typed_header::TypedHeader; use hyper::StatusCode; use lettre::Address; use mas_axum_utils::{ - FancyError, SessionInfoExt, + InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, CsrfToken, ProtectedForm}, }; -use mas_data_model::{CaptchaConfig, UserAgent}; +use mas_data_model::CaptchaConfig; use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; use mas_policy::Policy; @@ -66,7 +66,7 @@ pub struct QueryParams { action: OptionalPostAuthAction, } -#[tracing::instrument(name = "handlers.views.password_register.get", skip_all, err)] +#[tracing::instrument(name = "handlers.views.password_register.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, @@ -77,7 +77,7 @@ pub(crate) async fn get( mut repo: BoxRepository, Query(query): Query, cookie_jar: CookieJar, -) -> Result { +) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (session_info, cookie_jar) = cookie_jar.session_info(); @@ -118,7 +118,7 @@ pub(crate) async fn get( Ok((cookie_jar, Html(content)).into_response()) } -#[tracing::instrument(name = "handlers.views.password_register.post", skip_all, err)] +#[tracing::instrument(name = "handlers.views.password_register.post", skip_all)] #[allow(clippy::too_many_lines, clippy::too_many_arguments)] pub(crate) async fn post( mut rng: BoxRng, @@ -140,8 +140,8 @@ pub(crate) async fn post( Query(query): Query, cookie_jar: CookieJar, Form(form): Form>, -) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); +) -> Result { + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let ip_address = activity_tracker.ip(); if !site_config.password_registration_enabled { @@ -179,7 +179,11 @@ pub(crate) async fn post( } else if repo.user().exists(&form.username).await? { // The user already exists in the database state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } else if !homeserver.is_localpart_available(&form.username).await? { + } else if !homeserver + .is_localpart_available(&form.username) + .await + .map_err(InternalError::from_anyhow)? + { // The user already exists on the homeserver tracing::warn!( username = &form.username, @@ -239,7 +243,7 @@ pub(crate) async fn post( email: Some(&form.email), requester: mas_policy::Requester { ip_address: activity_tracker.ip(), - user_agent: user_agent.clone().map(|ua| ua.raw), + user_agent: user_agent.clone(), }, }) .await?; @@ -361,7 +365,10 @@ pub(crate) async fn post( // Hash the password let password = Zeroizing::new(form.password.into_bytes()); - let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + let (version, hashed_password) = password_manager + .hash(&mut rng, password) + .await + .map_err(InternalError::from_anyhow)?; // Add the password to the registration let registration = repo @@ -390,8 +397,11 @@ async fn render( repo: &mut impl RepositoryAccess, templates: &Templates, captcha_config: Option, -) -> Result { - let next = action.load_context(repo).await?; +) -> Result { + let next = action + .load_context(repo) + .await + .map_err(InternalError::from_anyhow)?; let ctx = if let Some(next) = next { ctx.with_post_action(next) } else { diff --git a/crates/handlers/src/views/register/steps/display_name.rs b/crates/handlers/src/views/register/steps/display_name.rs index a9ff90817..fa029475a 100644 --- a/crates/handlers/src/views/register/steps/display_name.rs +++ b/crates/handlers/src/views/register/steps/display_name.rs @@ -10,7 +10,7 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use mas_axum_utils::{ - FancyError, + InternalError, cookies::CookieJar, csrf::{CsrfExt as _, ProtectedForm}, }; @@ -49,7 +49,6 @@ impl ToFormState for DisplayNameForm { name = "handlers.views.register.steps.display_name.get", fields(user_registration.id = %id), skip_all, - err, )] pub(crate) async fn get( mut rng: BoxRng, @@ -60,14 +59,15 @@ pub(crate) async fn get( mut repo: BoxRepository, Path(id): Path, cookie_jar: CookieJar, -) -> Result { +) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let registration = repo .user_registration() .lookup(id) .await? - .context("Could not find user registration")?; + .context("Could not find user registration") + .map_err(InternalError::from_anyhow)?; // If the registration is completed, we can go to the registration destination // XXX: this might not be the right thing to do? Maybe an error page would be @@ -100,7 +100,6 @@ pub(crate) async fn get( name = "handlers.views.register.steps.display_name.post", fields(user_registration.id = %id), skip_all, - err, )] pub(crate) async fn post( mut rng: BoxRng, @@ -112,12 +111,13 @@ pub(crate) async fn post( Path(id): Path, cookie_jar: CookieJar, Form(form): Form>, -) -> Result { +) -> Result { let registration = repo .user_registration() .lookup(id) .await? - .context("Could not find user registration")?; + .context("Could not find user registration") + .map_err(InternalError::from_anyhow)?; // If the registration is completed, we can go to the registration destination // XXX: this might not be the right thing to do? Maybe an error page would be diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index 7c73825cc..fd0736b29 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -12,8 +12,7 @@ use axum::{ }; use axum_extra::TypedHeader; use chrono::Duration; -use mas_axum_utils::{FancyError, SessionInfoExt as _, cookies::CookieJar}; -use mas_data_model::UserAgent; +use mas_axum_utils::{InternalError, SessionInfoExt as _, cookies::CookieJar}; use mas_matrix::HomeserverConnection; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ @@ -42,7 +41,6 @@ static PASSWORD_REGISTER_COUNTER: LazyLock> = LazyLock::new(|| { name = "handlers.views.register.steps.finish.get", fields(user_registration.id = %id), skip_all, - err, )] pub(crate) async fn get( mut rng: BoxRng, @@ -56,13 +54,14 @@ pub(crate) async fn get( PreferredLanguage(lang): PreferredLanguage, cookie_jar: CookieJar, Path(id): Path, -) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); +) -> Result { + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let registration = repo .user_registration() .lookup(id) .await? - .context("User registration not found")?; + .context("User registration not found") + .map_err(InternalError::from_anyhow)?; // If the registration is completed, we can go to the registration destination // XXX: this might not be the right thing to do? Maybe an error page would be @@ -83,7 +82,7 @@ pub(crate) async fn get( // Make sure the registration session hasn't expired // XXX: this duration is hard-coded, could be configurable if clock.now() - registration.created_at > Duration::hours(1) { - return Err(FancyError::from(anyhow::anyhow!( + return Err(InternalError::from_anyhow(anyhow::anyhow!( "Registration session has expired" ))); } @@ -92,7 +91,7 @@ pub(crate) async fn get( let registrations = UserRegistrationSessions::load(&cookie_jar); if !registrations.contains(®istration) { // XXX: we should have a better error screen here - return Err(FancyError::from(anyhow::anyhow!( + return Err(InternalError::from_anyhow(anyhow::anyhow!( "Could not find the registration in the browser cookies" ))); } @@ -104,16 +103,17 @@ pub(crate) async fn get( if repo.user().exists(®istration.username).await? { // XXX: this could have a better error message, but as this is unlikely to // happen, we're fine with a vague message for now - return Err(FancyError::from(anyhow::anyhow!( + return Err(InternalError::from_anyhow(anyhow::anyhow!( "Username is already taken" ))); } if !homeserver .is_localpart_available(®istration.username) - .await? + .await + .map_err(InternalError::from_anyhow)? { - return Err(FancyError::from(anyhow::anyhow!( + return Err(InternalError::from_anyhow(anyhow::anyhow!( "Username is not available" ))); } @@ -122,12 +122,14 @@ pub(crate) async fn get( // change in the future let email_authentication_id = registration .email_authentication_id - .context("No email authentication started for this registration")?; + .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")?; + .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() { diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs index bba5b4728..bd291d5f0 100644 --- a/crates/handlers/src/views/register/steps/verify_email.rs +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -9,7 +9,7 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use mas_axum_utils::{ - FancyError, + InternalError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -37,7 +37,6 @@ impl ToFormState for CodeForm { name = "handlers.views.register.steps.verify_email.get", fields(user_registration.id = %id), skip_all, - err, )] pub(crate) async fn get( mut rng: BoxRng, @@ -48,14 +47,15 @@ pub(crate) async fn get( mut repo: BoxRepository, Path(id): Path, cookie_jar: CookieJar, -) -> Result { +) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let registration = repo .user_registration() .lookup(id) .await? - .context("Could not find user registration")?; + .context("Could not find user registration") + .map_err(InternalError::from_anyhow)?; // If the registration is completed, we can go to the registration destination // XXX: this might not be the right thing to do? Maybe an error page would be @@ -77,16 +77,18 @@ pub(crate) async fn get( let email_authentication_id = registration .email_authentication_id - .context("No email authentication started for this registration")?; + .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 find email authentication")?; + .context("Could not find email authentication") + .map_err(InternalError::from_anyhow)?; if email_authentication.completed_at.is_some() { // XXX: display a better error here - return Err(FancyError::from(anyhow::anyhow!( + return Err(InternalError::from_anyhow(anyhow::anyhow!( "Email authentication already completed" ))); } @@ -104,7 +106,6 @@ pub(crate) async fn get( name = "handlers.views.account_email_verify.post", fields(user_email.id = %id), skip_all, - err, )] pub(crate) async fn post( clock: BoxClock, @@ -117,14 +118,15 @@ pub(crate) async fn post( State(url_builder): State, Path(id): Path, Form(form): Form>, -) -> Result { +) -> Result { let form = cookie_jar.verify_form(&clock, form)?; let registration = repo .user_registration() .lookup(id) .await? - .context("Could not find user registration")?; + .context("Could not find user registration") + .map_err(InternalError::from_anyhow)?; // If the registration is completed, we can go to the registration destination // XXX: this might not be the right thing to do? Maybe an error page would be @@ -144,16 +146,18 @@ pub(crate) async fn post( let email_authentication_id = registration .email_authentication_id - .context("No email authentication started for this registration")?; + .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 find email authentication")?; + .context("Could not find email authentication") + .map_err(InternalError::from_anyhow)?; if email_authentication.completed_at.is_some() { // XXX: display a better error here - return Err(FancyError::from(anyhow::anyhow!( + return Err(InternalError::from_anyhow(anyhow::anyhow!( "Email authentication already completed" ))); } diff --git a/crates/http/src/reqwest.rs b/crates/http/src/reqwest.rs index b75d7fb07..561fb100f 100644 --- a/crates/http/src/reqwest.rs +++ b/crates/http/src/reqwest.rs @@ -98,7 +98,6 @@ pub fn client() -> reqwest::Client { .user_agent(USER_AGENT) .timeout(Duration::from_secs(60)) .connect_timeout(Duration::from_secs(30)) - .read_timeout(Duration::from_secs(30)) .build() .expect("failed to create HTTP client") } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 200b5e1ff..44fb06a5e 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -11,7 +11,7 @@ mod translator; pub use icu_calendar; pub use icu_datetime; pub use icu_locid::locale; -pub use icu_provider::DataLocale; +pub use icu_provider::{DataError, DataLocale}; pub use self::{ sprintf::{Argument, ArgumentList, Message}, diff --git a/crates/i18n/src/translator.rs b/crates/i18n/src/translator.rs index 07415ff26..68afb1793 100644 --- a/crates/i18n/src/translator.rs +++ b/crates/i18n/src/translator.rs @@ -345,8 +345,8 @@ impl Translator { /// Get a list of available locales. #[must_use] - pub fn available_locales(&self) -> Vec<&DataLocale> { - self.translations.keys().collect() + pub fn available_locales(&self) -> Vec { + self.translations.keys().cloned().collect() } /// Check if a locale is available. diff --git a/crates/keystore/Cargo.toml b/crates/keystore/Cargo.toml index 814ed860e..00c79ecc1 100644 --- a/crates/keystore/Cargo.toml +++ b/crates/keystore/Cargo.toml @@ -14,7 +14,7 @@ workspace = true [dependencies] aead = { version = "0.5.2", features = ["std"] } const-oid = { version = "0.9.6", features = ["std"] } -der = { version = "0.7.9", features = ["std"] } +der = { version = "0.7.10", features = ["std"] } elliptic-curve.workspace = true k256.workspace = true p256.workspace = true diff --git a/crates/listener/Cargo.toml b/crates/listener/Cargo.toml index 4f9056c0f..6d26a34c1 100644 --- a/crates/listener/Cargo.toml +++ b/crates/listener/Cargo.toml @@ -17,7 +17,7 @@ futures-util.workspace = true http-body.workspace = true hyper = { workspace = true, features = ["server"] } hyper-util.workspace = true -pin-project-lite = "0.2.16" +pin-project-lite.workspace = true socket2 = "0.5.9" thiserror.workspace = true tokio.workspace = true @@ -27,6 +27,8 @@ tower.workspace = true tower-http.workspace = true tracing.workspace = true +mas-context.workspace = true + [dev-dependencies] anyhow.workspace = true rustls-pemfile = "2.2.0" diff --git a/crates/listener/src/server.rs b/crates/listener/src/server.rs index b022455bb..f0b66820d 100644 --- a/crates/listener/src/server.rs +++ b/crates/listener/src/server.rs @@ -18,6 +18,7 @@ use hyper_util::{ server::conn::auto::Connection, service::TowerToHyperService, }; +use mas_context::LogContext; use pin_project_lite::pin_project; use thiserror::Error; use tokio_rustls::rustls::ServerConfig; @@ -107,12 +108,6 @@ impl Server { #[derive(Debug, Error)] #[non_exhaustive] enum AcceptError { - #[error("failed to accept connection from the underlying socket")] - Socket { - #[source] - source: std::io::Error, - }, - #[error("failed to complete the TLS handshake")] TlsHandshake { #[source] @@ -133,10 +128,6 @@ enum AcceptError { } impl AcceptError { - fn socket(source: std::io::Error) -> Self { - Self::Socket { source } - } - fn tls_handshake(source: std::io::Error) -> Self { Self::TlsHandshake { source } } @@ -164,7 +155,6 @@ impl AcceptError { network.peer.address, network.peer.port, ), - err, )] async fn accept( maybe_proxy_acceptor: &MaybeProxyAcceptor, @@ -357,12 +347,16 @@ pub async fn run_servers( // Poll on the JoinSet to collect connections to serve res = accept_tasks.join_next(), if !accept_tasks.is_empty() => { match res { - Some(Ok(Ok(connection))) => { - tracing::trace!("Accepted connection"); - let conn = AbortableConnection::new(connection, soft_shutdown_token.child_token()); - connection_tasks.spawn(conn); + Some(Ok(Some(connection))) => { + let token = soft_shutdown_token.child_token(); + connection_tasks.spawn(LogContext::new("http-serve").run(async move || { + tracing::debug!("Accepted connection"); + if let Err(e) = AbortableConnection::new(connection, token).await { + tracing::warn!(error = &*e as &dyn std::error::Error, "Failed to serve connection"); + } + })); }, - Some(Ok(Err(_e))) => { /* Connection did not finish handshake, error should be logged in `accept` */ }, + Some(Ok(None)) => { /* Connection did not finish handshake, error should be logged in `accept` */ }, Some(Err(e)) => tracing::error!(error = &e as &dyn std::error::Error, "Join error"), None => tracing::error!("Join set was polled even though it was empty"), } @@ -371,8 +365,7 @@ pub async fn run_servers( // Poll on the JoinSet to collect finished connections res = connection_tasks.join_next(), if !connection_tasks.is_empty() => { match res { - Some(Ok(Ok(()))) => tracing::trace!("Connection finished"), - Some(Ok(Err(e))) => tracing::error!(error = &*e as &dyn std::error::Error, "Error while serving connection"), + Some(Ok(())) => { /* Connection finished, any errors should be logged in in the spawned task */ }, Some(Err(e)) => tracing::error!(error = &e as &dyn std::error::Error, "Join error"), None => tracing::error!("Join set was polled even though it was empty"), } @@ -385,11 +378,23 @@ pub async fn run_servers( // Spawn the connection in the set, so we don't have to wait for the handshake to // accept the next connection. This allows us to keep track of active connections // and waiting on them for a graceful shutdown - accept_tasks.spawn(async move { - let (maybe_proxy_acceptor, maybe_tls_acceptor, service, peer_addr, stream) = res - .map_err(AcceptError::socket)?; - accept(&maybe_proxy_acceptor, &maybe_tls_acceptor, peer_addr, stream, service).await - }); + accept_tasks.spawn(LogContext::new("http-accept").run(async move || { + let (maybe_proxy_acceptor, maybe_tls_acceptor, service, peer_addr, stream) = match res { + Ok(res) => res, + Err(e) => { + tracing::warn!(error = &e as &dyn std::error::Error, "Failed to accept connection from the underlying socket"); + return None; + } + }; + + match accept(&maybe_proxy_acceptor, &maybe_tls_acceptor, peer_addr, stream, service).await { + Ok(connection) => Some(connection), + Err(e) => { + tracing::warn!(error = &e as &dyn std::error::Error, "Failed to accept connection"); + None + } + } + })); }, }; } @@ -409,12 +414,16 @@ pub async fn run_servers( // Poll on the JoinSet to collect connections to serve res = accept_tasks.join_next(), if !accept_tasks.is_empty() => { match res { - Some(Ok(Ok(connection))) => { - tracing::trace!("Accepted connection"); - let conn = AbortableConnection::new(connection, soft_shutdown_token.child_token()); - connection_tasks.spawn(conn); + Some(Ok(Some(connection))) => { + let token = soft_shutdown_token.child_token(); + connection_tasks.spawn(LogContext::new("http-serve").run(async || { + tracing::debug!("Accepted connection"); + if let Err(e) = AbortableConnection::new(connection, token).await { + tracing::warn!(error = &*e as &dyn std::error::Error, "Failed to serve connection"); + } + })); } - Some(Ok(Err(_e))) => { /* Connection did not finish handshake, error should be logged in `accept` */ }, + Some(Ok(None)) => { /* Connection did not finish handshake, error should be logged in `accept` */ }, Some(Err(e)) => tracing::error!(error = &e as &dyn std::error::Error, "Join error"), None => tracing::error!("Join set was polled even though it was empty"), } @@ -423,8 +432,7 @@ pub async fn run_servers( // Poll on the JoinSet to collect finished connections res = connection_tasks.join_next(), if !connection_tasks.is_empty() => { match res { - Some(Ok(Ok(()))) => tracing::trace!("Connection finished"), - Some(Ok(Err(e))) => tracing::error!(error = &*e as &dyn std::error::Error, "Error while serving connection"), + Some(Ok(())) => { /* Connection finished, any errors should be logged in in the spawned task */ }, Some(Err(e)) => tracing::error!(error = &e as &dyn std::error::Error, "Join error"), None => tracing::error!("Join set was polled even though it was empty"), } diff --git a/crates/matrix-synapse/src/lib.rs b/crates/matrix-synapse/src/lib.rs index 1b6627afe..5ca7daa9e 100644 --- a/crates/matrix-synapse/src/lib.rs +++ b/crates/matrix-synapse/src/lib.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use std::collections::HashSet; +use std::{collections::HashSet, time::Duration}; use anyhow::{Context, bail}; use error::SynapseResponseExt; @@ -133,6 +133,11 @@ struct SynapseDevice { dehydrated: Option, } +#[derive(Serialize)] +struct SynapseUpdateDeviceRequest<'a> { + display_name: Option<&'a str>, +} + #[derive(Serialize)] struct SynapseDeleteDevicesRequest { devices: Vec, @@ -174,10 +179,10 @@ impl HomeserverConnection for SynapseConnection { err(Debug), )] async fn query_user(&self, mxid: &str) -> Result { - let mxid = urlencoding::encode(mxid); + let encoded_mxid = urlencoding::encode(mxid); let response = self - .get(&format!("_synapse/admin/v2/users/{mxid}")) + .get(&format!("_synapse/admin/v2/users/{encoded_mxid}")) .send_traced() .await .context("Failed to query user from Synapse")?; @@ -209,6 +214,12 @@ impl HomeserverConnection for SynapseConnection { 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 @@ -282,9 +293,9 @@ impl HomeserverConnection for SynapseConnection { ); }); - let mxid = urlencoding::encode(request.mxid()); + let encoded_mxid = urlencoding::encode(request.mxid()); let response = self - .put(&format!("_synapse/admin/v2/users/{mxid}")) + .put(&format!("_synapse/admin/v2/users/{encoded_mxid}")) .json(&body) .send_traced() .await @@ -312,11 +323,16 @@ impl HomeserverConnection for SynapseConnection { ), err(Debug), )] - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); + 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/{mxid}/devices")) + .post(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) .json(&SynapseDevice { device_id: device_id.to_owned(), dehydrated: None, @@ -337,6 +353,57 @@ impl HomeserverConnection for SynapseConnection { ); } + // 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(()) } @@ -351,12 +418,12 @@ impl HomeserverConnection for SynapseConnection { err(Debug), )] async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); - let device_id = urlencoding::encode(device_id); + let encoded_mxid = urlencoding::encode(mxid); + let encoded_device_id = urlencoding::encode(device_id); let response = self .delete(&format!( - "_synapse/admin/v2/users/{mxid}/devices/{device_id}" + "_synapse/admin/v2/users/{encoded_mxid}/devices/{encoded_device_id}" )) .send_traced() .await @@ -392,10 +459,10 @@ impl HomeserverConnection for SynapseConnection { devices: HashSet, ) -> Result<(), anyhow::Error> { // Get the list of current devices - let mxid_url = urlencoding::encode(mxid); + let encoded_mxid = urlencoding::encode(mxid); let response = self - .get(&format!("_synapse/admin/v2/users/{mxid_url}/devices")) + .get(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) .send_traced() .await .context("Failed to query devices from Synapse")?; @@ -426,7 +493,7 @@ impl HomeserverConnection for SynapseConnection { let response = self .post(&format!( - "_synapse/admin/v2/users/{mxid_url}/delete_devices" + "_synapse/admin/v2/users/{encoded_mxid}/delete_devices" )) .json(&SynapseDeleteDevicesRequest { devices: to_delete }) .send_traced() @@ -448,7 +515,7 @@ impl HomeserverConnection for SynapseConnection { // 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).await?; + self.create_device(mxid, device_id, None).await?; } Ok(()) @@ -465,11 +532,13 @@ impl HomeserverConnection for SynapseConnection { err(Debug), )] async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); + let encoded_mxid = urlencoding::encode(mxid); let response = self - .post(&format!("_synapse/admin/v1/deactivate/{mxid}")) + .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")?; @@ -499,9 +568,9 @@ impl HomeserverConnection for SynapseConnection { err(Debug), )] async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); + let encoded_mxid = urlencoding::encode(mxid); let response = self - .put(&format!("_synapse/admin/v2/users/{mxid}")) + .put(&format!("_synapse/admin/v2/users/{encoded_mxid}")) .json(&SynapseUser { deactivated: Some(false), ..SynapseUser::default() @@ -532,9 +601,11 @@ impl HomeserverConnection for SynapseConnection { err(Debug), )] async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); + let encoded_mxid = urlencoding::encode(mxid); let response = self - .put(&format!("_matrix/client/v3/profile/{mxid}/displayname")) + .put(&format!( + "_matrix/client/v3/profile/{encoded_mxid}/displayname" + )) .json(&SetDisplayNameRequest { displayname }) .send_traced() .await @@ -578,11 +649,11 @@ impl HomeserverConnection for SynapseConnection { err(Debug), )] async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); + let encoded_mxid = urlencoding::encode(mxid); let response = self .post(&format!( - "_synapse/admin/v1/users/{mxid}/_allow_cross_signing_replacement_without_uia" + "_synapse/admin/v1/users/{encoded_mxid}/_allow_cross_signing_replacement_without_uia" )) .json(&SynapseAllowCrossSigningResetRequest {}) .send_traced() diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index 59cdb4880..ae8a4e563 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -254,7 +254,31 @@ pub trait HomeserverConnection: Send + Sync { /// /// Returns an error if the homeserver is unreachable or the device could /// not be created. - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error>; + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error>; + + /// Update the display name of a device for a user on the homeserver. + /// + /// # Parameters + /// + /// * `mxid` - The Matrix ID of the user to update a device for. + /// * `device_id` - The device ID to update. + /// * `display_name` - The new display name to set + /// + /// # Errors + /// + /// Returns an error if the homeserver is unreachable or the device could + /// not be updated. + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error>; /// Delete a device for a user on the homeserver. /// @@ -364,8 +388,26 @@ impl HomeserverConnection for &T (**self).is_localpart_available(localpart).await } - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - (**self).create_device(mxid, device_id).await + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + (**self) + .create_device(mxid, device_id, initial_display_name) + .await + } + + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + (**self) + .update_device_display_name(mxid, device_id, display_name) + .await } async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { @@ -420,8 +462,26 @@ impl HomeserverConnection for Arc { (**self).is_localpart_available(localpart).await } - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - (**self).create_device(mxid, device_id).await + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + (**self) + .create_device(mxid, device_id, initial_display_name) + .await + } + + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + (**self) + .update_device_display_name(mxid, device_id, display_name) + .await } async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 22b9a43d5..7c7973ce0 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -107,13 +107,30 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(!users.contains_key(&mxid)) } - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { + async fn create_device( + &self, + mxid: &str, + device_id: &str, + _initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { let mut users = self.users.write().await; 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, + device_id: &str, + _display_name: &str, + ) -> Result<(), anyhow::Error> { + let mut users = self.users.write().await; + 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> { let mut users = self.users.write().await; let user = users.get_mut(mxid).context("User not found")?; @@ -191,7 +208,7 @@ mod tests { assert_eq!(conn.mxid("test"), mxid); assert!(conn.query_user(mxid).await.is_err()); - assert!(conn.create_device(mxid, device).await.is_err()); + assert!(conn.create_device(mxid, device, None).await.is_err()); assert!(conn.delete_device(mxid, device).await.is_err()); let request = ProvisionRequest::new("@test:example.org", "test") @@ -222,9 +239,9 @@ mod tests { assert!(conn.delete_device(mxid, device).await.is_ok()); // Create the device - assert!(conn.create_device(mxid, device).await.is_ok()); + assert!(conn.create_device(mxid, device, None).await.is_ok()); // Create the same device again - assert!(conn.create_device(mxid, device).await.is_ok()); + assert!(conn.create_device(mxid, device, None).await.is_ok()); // XXX: there is no API to query devices yet in the trait // Delete the device diff --git a/crates/matrix/src/readonly.rs b/crates/matrix/src/readonly.rs index b51040080..530c3cd89 100644 --- a/crates/matrix/src/readonly.rs +++ b/crates/matrix/src/readonly.rs @@ -40,10 +40,24 @@ impl HomeserverConnection for ReadOnlyHomeserverConnect self.inner.is_localpart_available(localpart).await } - async fn create_device(&self, _mxid: &str, _device_id: &str) -> Result<(), anyhow::Error> { + async fn create_device( + &self, + _mxid: &str, + _device_id: &str, + _initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { anyhow::bail!("Device creation is not supported in read-only mode"); } + async fn update_device_display_name( + &self, + _mxid: &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> { anyhow::bail!("Device deletion is not supported in read-only mode"); } diff --git a/crates/oidc-client/src/requests/authorization_code.rs b/crates/oidc-client/src/requests/authorization_code.rs index 198846876..9271fe33c 100644 --- a/crates/oidc-client/src/requests/authorization_code.rs +++ b/crates/oidc-client/src/requests/authorization_code.rs @@ -191,7 +191,9 @@ pub struct AuthorizationValidationData { pub state: String, /// A string to mitigate replay attacks. - pub nonce: String, + /// Used when the `openid` scope is set (and therefore we are using OpenID + /// Connect). + pub nonce: Option, /// The URI where the end-user will be redirected after authorization. pub redirect_uri: Url, @@ -216,7 +218,7 @@ fn build_authorization_request( ) -> Result<(FullAuthorizationRequest, AuthorizationValidationData), AuthorizationError> { let AuthorizationRequestData { client_id, - mut scope, + scope, redirect_uri, code_challenge_methods_supported, display, @@ -229,9 +231,13 @@ fn build_authorization_request( response_mode, } = authorization_data; + let is_openid = scope.contains(&OPENID); + // Generate a random CSRF "state" token and a nonce. let state = Alphanumeric.sample_string(rng, 16); - let nonce = Alphanumeric.sample_string(rng, 16); + + // Generate a random nonce if we're in 'OpenID Connect' mode + let nonce = is_openid.then(|| Alphanumeric.sample_string(rng, 16)); // Use PKCE, whenever possible. let (pkce, code_challenge_verifier) = if code_challenge_methods_supported @@ -255,8 +261,6 @@ fn build_authorization_request( (None, None) }; - scope.insert(OPENID); - let auth_request = FullAuthorizationRequest { inner: AuthorizationRequest { response_type: OAuthAuthorizationEndpointResponseType::Code.into(), @@ -265,7 +269,7 @@ fn build_authorization_request( scope, state: Some(state.clone()), response_mode, - nonce: Some(nonce.clone()), + nonce: nonce.clone(), display, prompt, max_age, @@ -442,10 +446,12 @@ pub async fn access_token_with_authorization_code( .extract_optional_with_options(&mut claims, TokenHash::new(signing_alg, &code)) .map_err(IdTokenError::from)?; - // Nonce must match. - claims::NONCE - .extract_required_with_options(&mut claims, validation_data.nonce.as_str()) - .map_err(IdTokenError::from)?; + // Nonce must match if we have one. + if let Some(nonce) = validation_data.nonce.as_deref() { + claims::NONCE + .extract_required_with_options(&mut claims, nonce) + .map_err(IdTokenError::from)?; + } Some(id_token.into_owned()) } else { diff --git a/crates/oidc-client/tests/it/requests/authorization_code.rs b/crates/oidc-client/tests/it/requests/authorization_code.rs index 131db26e9..d0eb2a8c1 100644 --- a/crates/oidc-client/tests/it/requests/authorization_code.rs +++ b/crates/oidc-client/tests/it/requests/authorization_code.rs @@ -186,7 +186,7 @@ async fn pass_access_token_with_authorization_code() { let redirect_uri = Url::parse(REDIRECT_URI).unwrap(); let validation_data = AuthorizationValidationData { state: "some_state".to_owned(), - nonce: NONCE.to_owned(), + nonce: Some(NONCE.to_owned()), redirect_uri, code_challenge_verifier: Some(CODE_VERIFIER.to_owned()), }; @@ -244,7 +244,7 @@ async fn fail_access_token_with_authorization_code_wrong_nonce() { let redirect_uri = Url::parse(REDIRECT_URI).unwrap(); let validation_data = AuthorizationValidationData { state: "some_state".to_owned(), - nonce: "wrong_nonce".to_owned(), + nonce: Some("wrong_nonce".to_owned()), redirect_uri, code_challenge_verifier: Some(CODE_VERIFIER.to_owned()), }; @@ -306,7 +306,7 @@ async fn fail_access_token_with_authorization_code_no_id_token() { let nonce = "some_nonce".to_owned(); let validation_data = AuthorizationValidationData { state: "some_state".to_owned(), - nonce: nonce.clone(), + nonce: Some(nonce.clone()), redirect_uri, code_challenge_verifier: Some(CODE_VERIFIER.to_owned()), }; diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index c8b771b6d..5a714e9a2 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -197,7 +197,7 @@ pub struct PolicyFactory { } impl PolicyFactory { - #[tracing::instrument(name = "policy.load", skip(source), err)] + #[tracing::instrument(name = "policy.load", skip(source))] pub async fn load( mut source: impl AsyncRead + std::marker::Unpin, data: Data, @@ -283,7 +283,7 @@ impl PolicyFactory { Ok(true) } - #[tracing::instrument(name = "policy.instantiate", skip_all, err)] + #[tracing::instrument(name = "policy.instantiate", skip_all)] pub async fn instantiate(&self) -> Result { let data = self.dynamic_data.load(); self.instantiate_with_data(&data.merged).await @@ -342,7 +342,6 @@ impl Policy { fields( %input.email, ), - err, )] pub async fn evaluate_email( &mut self, @@ -364,7 +363,6 @@ impl Policy { input.username = input.username, input.email = input.email, ), - err, )] pub async fn evaluate_register( &mut self, @@ -402,7 +400,6 @@ impl Policy { %input.scope, %input.client.id, ), - err, )] pub async fn evaluate_authorization_grant( &mut self, diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 89a9d726e..a7efeade9 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -548,6 +548,13 @@ impl SimpleRoute for CompatLogout { const PATH: &'static str = "/_matrix/client/{version}/logout"; } +/// `POST /_matrix/client/v3/logout/all` +pub struct CompatLogoutAll; + +impl SimpleRoute for CompatLogoutAll { + const PATH: &'static str = "/_matrix/client/{version}/logout/all"; +} + /// `POST /_matrix/client/v3/refresh` pub struct CompatRefresh; diff --git a/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json b/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json index eac08aed7..0e28ac022 100644 --- a/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json +++ b/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json @@ -80,7 +80,7 @@ true, false, true, - false, + true, true, true, true, diff --git a/crates/storage-pg/.sqlx/query-5236305c49b1ee99a00e32df3727ebe97b523b6836e1696d8b8e2a0ef70bfa44.json b/crates/storage-pg/.sqlx/query-5236305c49b1ee99a00e32df3727ebe97b523b6836e1696d8b8e2a0ef70bfa44.json deleted file mode 100644 index 23b06789c..000000000 --- a/crates/storage-pg/.sqlx/query-5236305c49b1ee99a00e32df3727ebe97b523b6836e1696d8b8e2a0ef70bfa44.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , token_endpoint_auth_method\n , jwks\n , jwks_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE)\n ON CONFLICT (oauth2_client_id)\n DO\n UPDATE SET encrypted_client_secret = EXCLUDED.encrypted_client_secret\n , redirect_uris = EXCLUDED.redirect_uris\n , grant_type_authorization_code = EXCLUDED.grant_type_authorization_code\n , grant_type_refresh_token = EXCLUDED.grant_type_refresh_token\n , grant_type_client_credentials = EXCLUDED.grant_type_client_credentials\n , grant_type_device_code = EXCLUDED.grant_type_device_code\n , token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method\n , jwks = EXCLUDED.jwks\n , jwks_uri = EXCLUDED.jwks_uri\n , is_static = TRUE\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "TextArray", - "Bool", - "Bool", - "Bool", - "Bool", - "Text", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "5236305c49b1ee99a00e32df3727ebe97b523b6836e1696d8b8e2a0ef70bfa44" -} diff --git a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json b/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json new file mode 100644 index 000000000..a7b63ca21 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json @@ -0,0 +1,45 @@ +{ + "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-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json b/crates/storage-pg/.sqlx/query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json similarity index 82% rename from crates/storage-pg/.sqlx/query-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json rename to crates/storage-pg/.sqlx/query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json index 5fae1ffab..a7b95fc91 100644 --- a/crates/storage-pg/.sqlx/query-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json +++ b/crates/storage-pg/.sqlx/query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_session_id\n , user_id\n , user_session_id\n , oauth2_client_id\n , scope_list\n , created_at\n , finished_at\n , user_agent\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n ", + "query": "\n SELECT oauth2_session_id\n , user_id\n , user_session_id\n , oauth2_client_id\n , scope_list\n , created_at\n , finished_at\n , user_agent\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n , human_name\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n ", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "last_active_ip: IpAddr", "type_info": "Inet" + }, + { + "ordinal": 10, + "name": "human_name", + "type_info": "Text" } ], "parameters": { @@ -69,8 +74,9 @@ true, true, true, + true, true ] }, - "hash": "5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5" + "hash": "6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6" } diff --git a/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json b/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json deleted file mode 100644 index 7ab023046..000000000 --- a/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json +++ /dev/null @@ -1,44 +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 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)\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 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", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a" -} diff --git a/crates/storage-pg/.sqlx/query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json b/crates/storage-pg/.sqlx/query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json similarity index 76% rename from crates/storage-pg/.sqlx/query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json rename to crates/storage-pg/.sqlx/query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json index 2f372898b..22c3bc0eb 100644 --- a/crates/storage-pg/.sqlx/query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json +++ b/crates/storage-pg/.sqlx/query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n ", + "query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n locale,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ", "describe": { "columns": [], "parameters": { @@ -18,10 +18,11 @@ "Bool", "Text", "Text", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28" + "hash": "7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567" } diff --git a/crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json b/crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json new file mode 100644 index 000000000..44352005e --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth2_sessions\n SET human_name = $2\n WHERE oauth2_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc" +} diff --git a/crates/storage-pg/.sqlx/query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json b/crates/storage-pg/.sqlx/query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json similarity index 87% rename from crates/storage-pg/.sqlx/query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json rename to crates/storage-pg/.sqlx/query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json index d8fd25487..0a5d83f0a 100644 --- a/crates/storage-pg/.sqlx/query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json +++ b/crates/storage-pg/.sqlx/query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ", + "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ", "describe": { "columns": [ { @@ -90,6 +90,11 @@ }, { "ordinal": 17, + "name": "locale", + "type_info": "Text" + }, + { + "ordinal": 18, "name": "oauth2_session_id", "type_info": "Uuid" } @@ -117,8 +122,9 @@ true, true, true, + true, true ] }, - "hash": "890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251" + "hash": "8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035" } diff --git a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json b/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json similarity index 78% rename from crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json rename to crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json index 1a2a19d81..9944e855b 100644 --- a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json +++ b/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.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 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)\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 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 ", "describe": { "columns": [], "parameters": { @@ -25,10 +25,11 @@ "Text", "Text", "Text", + "Bool", "Timestamptz" ] }, "nullable": [] }, - "hash": "e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9" + "hash": "a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c" } diff --git a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json b/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json similarity index 90% rename from crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json rename to crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json index 65b97215c..7c1a26a86 100644 --- a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json +++ b/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.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 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 FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -117,6 +117,11 @@ "ordinal": 22, "name": "additional_parameters: Json>", "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "forward_login_hint", + "type_info": "Bool" } ], "parameters": { @@ -147,8 +152,9 @@ false, false, true, - true + true, + false ] }, - "hash": "1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e" + "hash": "a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89" } diff --git a/crates/storage-pg/.sqlx/query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json b/crates/storage-pg/.sqlx/query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json similarity index 86% rename from crates/storage-pg/.sqlx/query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json rename to crates/storage-pg/.sqlx/query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json index 7a52e4781..20cd2c704 100644 --- a/crates/storage-pg/.sqlx/query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json +++ b/crates/storage-pg/.sqlx/query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ", + "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ", "describe": { "columns": [ { @@ -90,6 +90,11 @@ }, { "ordinal": 17, + "name": "locale", + "type_info": "Text" + }, + { + "ordinal": 18, "name": "oauth2_session_id", "type_info": "Uuid" } @@ -117,8 +122,9 @@ true, true, true, + true, true ] }, - "hash": "bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4" + "hash": "c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e" } diff --git a/crates/storage-pg/.sqlx/query-da02f93d7346992a9795f12b900f91ac0b326dd751c0d374d6ef4d19f671d22e.json b/crates/storage-pg/.sqlx/query-da02f93d7346992a9795f12b900f91ac0b326dd751c0d374d6ef4d19f671d22e.json new file mode 100644 index 000000000..378ca2d78 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-da02f93d7346992a9795f12b900f91ac0b326dd751c0d374d6ef4d19f671d22e.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , grant_type_device_code\n , token_endpoint_auth_method\n , jwks\n , client_name\n , jwks_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, TRUE)\n ON CONFLICT (oauth2_client_id)\n DO\n UPDATE SET encrypted_client_secret = EXCLUDED.encrypted_client_secret\n , redirect_uris = EXCLUDED.redirect_uris\n , grant_type_authorization_code = EXCLUDED.grant_type_authorization_code\n , grant_type_refresh_token = EXCLUDED.grant_type_refresh_token\n , grant_type_client_credentials = EXCLUDED.grant_type_client_credentials\n , grant_type_device_code = EXCLUDED.grant_type_device_code\n , token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method\n , jwks = EXCLUDED.jwks\n , client_name = EXCLUDED.client_name\n , jwks_uri = EXCLUDED.jwks_uri\n , is_static = TRUE\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray", + "Bool", + "Bool", + "Bool", + "Bool", + "Text", + "Jsonb", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "da02f93d7346992a9795f12b900f91ac0b326dd751c0d374d6ef4d19f671d22e" +} diff --git a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json b/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json similarity index 88% rename from crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json rename to crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json index b929df201..d544590c4 100644 --- a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json +++ b/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.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 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 FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", "describe": { "columns": [ { @@ -117,6 +117,11 @@ "ordinal": 22, "name": "additional_parameters: Json>", "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "forward_login_hint", + "type_info": "Bool" } ], "parameters": { @@ -145,8 +150,9 @@ false, false, true, - true + true, + false ] }, - "hash": "c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178" + "hash": "e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917" } diff --git a/crates/storage-pg/.sqlx/query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json b/crates/storage-pg/.sqlx/query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json similarity index 56% rename from crates/storage-pg/.sqlx/query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json rename to crates/storage-pg/.sqlx/query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json index 35f6b5973..04ad6dd39 100644 --- a/crates/storage-pg/.sqlx/query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json +++ b/crates/storage-pg/.sqlx/query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO compat_sessions\n (compat_session_id, user_id, device_id,\n user_session_id, created_at, is_synapse_admin)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "query": "\n INSERT INTO compat_sessions\n (compat_session_id, user_id, device_id,\n user_session_id, created_at, is_synapse_admin,\n human_name)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", "describe": { "columns": [], "parameters": { @@ -10,10 +10,11 @@ "Text", "Uuid", "Timestamptz", - "Bool" + "Bool", + "Text" ] }, "nullable": [] }, - "hash": "cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766" + "hash": "e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c" } diff --git a/crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json b/crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json new file mode 100644 index 000000000..2ebaa4479 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE compat_sessions\n SET human_name = $2\n WHERE compat_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d" +} diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml index 6b45fb0a6..2a5e50447 100644 --- a/crates/storage-pg/Cargo.toml +++ b/crates/storage-pg/Cargo.toml @@ -21,6 +21,7 @@ serde_json.workspace = true thiserror.workspace = true tracing.workspace = true futures-util.workspace = true +opentelemetry.workspace = true opentelemetry-semantic-conventions.workspace = true rand.workspace = true diff --git a/crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql b/crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql new file mode 100644 index 000000000..699f70cf1 --- /dev/null +++ b/crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Track the locale of the user which asked for the authorization grant +ALTER TABLE oauth2_authorization_grants + ADD COLUMN locale TEXT; diff --git a/crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql b/crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql new file mode 100644 index 000000000..82a07c6d7 --- /dev/null +++ b/crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Add a user-provided human name to OAuth 2.0 sessions +ALTER TABLE oauth2_sessions + ADD COLUMN human_name TEXT; diff --git a/crates/storage-pg/migrations/20250506161158_upstream_oauth2_forward_login_hint.sql b/crates/storage-pg/migrations/20250506161158_upstream_oauth2_forward_login_hint.sql new file mode 100644 index 000000000..2aa29a821 --- /dev/null +++ b/crates/storage-pg/migrations/20250506161158_upstream_oauth2_forward_login_hint.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Add the forward_login_hint column to the upstream_oauth_providers table +ALTER TABLE "upstream_oauth_providers" + ADD COLUMN "forward_login_hint" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/storage-pg/migrations/20250507131948_upstream_oauth_session_optional_nonce.sql b/crates/storage-pg/migrations/20250507131948_upstream_oauth_session_optional_nonce.sql new file mode 100644 index 000000000..1b637c91b --- /dev/null +++ b/crates/storage-pg/migrations/20250507131948_upstream_oauth_session_optional_nonce.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Make the nonce column optional on the upstream oauth sessions +ALTER TABLE "upstream_oauth_authorization_sessions" + ALTER COLUMN "nonce" DROP NOT NULL; diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index d54c604b3..cd5e40b53 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -7,9 +7,7 @@ //! A module containing PostgreSQL implementation of repositories for sessions use async_trait::async_trait; -use mas_data_model::{ - CompatSession, CompatSessionState, Device, Session, SessionState, User, UserAgent, -}; +use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState, User}; use mas_storage::{ Clock, Page, Pagination, app_session::{AppSession, AppSessionFilter, AppSessionRepository, AppSessionState}, @@ -106,7 +104,6 @@ impl TryFrom for AppSession { last_active_ip, } = value; - let user_agent = user_agent.map(UserAgent::parse); let user_session_id = user_session_id.map(Ulid::from); match ( @@ -195,6 +192,7 @@ impl TryFrom for AppSession { user_agent, last_active_at, last_active_ip, + human_name, }; Ok(AppSession::OAuth2(Box::new(session))) @@ -302,7 +300,10 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { AppSessionLookupIden::ScopeList, ) .expr_as(Expr::cust("NULL"), AppSessionLookupIden::DeviceId) - .expr_as(Expr::cust("NULL"), AppSessionLookupIden::HumanName) + .expr_as( + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)), + AppSessionLookupIden::HumanName, + ) .expr_as( Expr::col((OAuth2Sessions::Table, OAuth2Sessions::CreatedAt)), AppSessionLookupIden::CreatedAt, @@ -574,7 +575,7 @@ mod tests { let device = Device::generate(&mut rng); let compat_session = repo .compat_session() - .add(&mut rng, &clock, &user, device.clone(), None, false) + .add(&mut rng, &clock, &user, device.clone(), None, false, None) .await .unwrap(); diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index 1d0e40426..60332fd50 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -20,7 +20,7 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::{Device, UserAgent}; + use mas_data_model::Device; use mas_storage::{ Clock, Pagination, RepositoryAccess, clock::MockClock, @@ -79,7 +79,7 @@ mod tests { let device_str = device.as_str().to_owned(); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device.clone(), None, false) + .add(&mut rng, &clock, &user, device.clone(), None, false, None) .await .unwrap(); assert_eq!(session.user_id, user.id); @@ -125,7 +125,7 @@ mod tests { assert!(session_lookup.user_agent.is_none()); let session = repo .compat_session() - .record_user_agent(session_lookup, UserAgent::parse("Mozilla/5.0".to_owned())) + .record_user_agent(session_lookup, "Mozilla/5.0".to_owned()) .await .unwrap(); assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0")); @@ -227,6 +227,7 @@ mod tests { device, Some(&browser_session), false, + None, ) .await .unwrap(); @@ -331,7 +332,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, false) + .add(&mut rng, &clock, &user, device, None, false, None) .await .unwrap(); @@ -452,7 +453,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, false) + .add(&mut rng, &clock, &user, device, None, false, None) .await .unwrap(); @@ -618,7 +619,7 @@ mod tests { let device = Device::generate(&mut rng); let compat_session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, false) + .add(&mut rng, &clock, &user, device, None, false, None) .await .unwrap(); diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index 10c9fd9ad..31f012477 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, - User, UserAgent, + User, }; use mas_storage::{ Clock, Page, Pagination, @@ -77,7 +77,7 @@ impl From for CompatSession { human_name: value.human_name, created_at: value.created_at, is_synapse_admin: value.is_synapse_admin, - user_agent: value.user_agent.map(UserAgent::parse), + user_agent: value.user_agent, last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, } @@ -126,7 +126,7 @@ impl TryFrom for (CompatSession, Option { device: Device, browser_session: Option<&BrowserSession>, is_synapse_admin: bool, + human_name: Option, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -314,8 +315,9 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { r#" INSERT INTO compat_sessions (compat_session_id, user_id, device_id, - user_session_id, created_at, is_synapse_admin) - VALUES ($1, $2, $3, $4, $5, $6) + user_session_id, created_at, is_synapse_admin, + human_name) + VALUES ($1, $2, $3, $4, $5, $6, $7) "#, Uuid::from(id), Uuid::from(user.id), @@ -323,6 +325,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { browser_session.map(|s| Uuid::from(s.id)), created_at, is_synapse_admin, + human_name.as_deref(), ) .traced() .execute(&mut *self.conn) @@ -333,7 +336,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { state: CompatSessionState::default(), user_id: user.id, device: Some(device), - human_name: None, + human_name, user_session_id: browser_session.map(|s| s.id), created_at, is_synapse_admin, @@ -549,13 +552,16 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { )] async fn record_batch_activity( &mut self, - activity: Vec<(Ulid, DateTime, Option)>, + mut activities: Vec<(Ulid, DateTime, Option)>, ) -> Result<(), Self::Error> { - let mut ids = Vec::with_capacity(activity.len()); - let mut last_activities = Vec::with_capacity(activity.len()); - let mut ips = Vec::with_capacity(activity.len()); + // 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 activity { + for (id, last_activity, ip) in activities { ids.push(Uuid::from(id)); last_activities.push(last_activity); ips.push(ip); @@ -598,7 +604,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { async fn record_user_agent( &mut self, mut compat_session: CompatSession, - user_agent: UserAgent, + user_agent: String, ) -> Result { let res = sqlx::query!( r#" @@ -619,4 +625,38 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { Ok(compat_session) } + + #[tracing::instrument( + name = "repository.compat_session.set_human_name", + skip(self), + fields( + compat_session.id = %compat_session.id, + compat_session.human_name = ?human_name, + ), + err, + )] + async fn set_human_name( + &mut self, + mut compat_session: CompatSession, + human_name: Option, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE compat_sessions + SET human_name = $2 + WHERE compat_session_id = $1 + "#, + Uuid::from(compat_session.id), + human_name.as_deref(), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + compat_session.human_name = human_name; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + Ok(compat_session) + } } diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 71e6f7591..76067b2fa 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -83,6 +83,7 @@ pub enum OAuth2Sessions { UserAgent, LastActiveAt, LastActiveIp, + HumanName, } #[derive(sea_query::Iden)] @@ -118,6 +119,7 @@ pub enum UpstreamOAuthProviders { PkceMode, ResponseMode, AdditionalParameters, + ForwardLoginHint, JwksUriOverride, TokenEndpointOverride, AuthorizationEndpointOverride, diff --git a/crates/storage-pg/src/lib.rs b/crates/storage-pg/src/lib.rs index 8971488a5..30882cfa8 100644 --- a/crates/storage-pg/src/lib.rs +++ b/crates/storage-pg/src/lib.rs @@ -175,10 +175,15 @@ pub(crate) mod iden; pub(crate) mod pagination; pub(crate) mod policy_data; pub(crate) mod repository; +pub(crate) mod telemetry; pub(crate) mod tracing; pub(crate) use self::errors::DatabaseInconsistencyError; -pub use self::{errors::DatabaseError, repository::PgRepository, tracing::ExecuteExt}; +pub use self::{ + errors::DatabaseError, + repository::{PgRepository, PgRepositoryFactory}, + tracing::ExecuteExt, +}; /// Embedded migrations, allowing them to run on startup pub static MIGRATOR: Migrator = { diff --git a/crates/storage-pg/src/oauth2/authorization_grant.rs b/crates/storage-pg/src/oauth2/authorization_grant.rs index d619573e7..59c5c2338 100644 --- a/crates/storage-pg/src/oauth2/authorization_grant.rs +++ b/crates/storage-pg/src/oauth2/authorization_grant.rs @@ -52,6 +52,7 @@ struct GrantLookup { code_challenge: Option, code_challenge_method: Option, login_hint: Option, + locale: Option, oauth2_client_id: Uuid, oauth2_session_id: Option, } @@ -162,6 +163,7 @@ impl TryFrom for AuthorizationGrant { created_at: value.created_at, response_type_id_token: value.response_type_id_token, login_hint: value.login_hint, + locale: value.locale, }) } } @@ -194,6 +196,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository response_mode: ResponseMode, response_type_id_token: bool, login_hint: Option, + locale: Option, ) -> Result { let code_challenge = code .as_ref() @@ -225,10 +228,11 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository response_type_id_token, authorization_code, login_hint, + locale, created_at ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) "#, Uuid::from(id), Uuid::from(client.id), @@ -243,6 +247,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository response_type_id_token, code_str, login_hint, + locale, created_at, ) .traced() @@ -262,6 +267,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository created_at, response_type_id_token, login_hint, + locale, }) } @@ -295,6 +301,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository , code_challenge , code_challenge_method , login_hint + , locale , oauth2_session_id FROM oauth2_authorization_grants @@ -344,6 +351,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository , code_challenge , code_challenge_method , login_hint + , locale , oauth2_session_id FROM oauth2_authorization_grants diff --git a/crates/storage-pg/src/oauth2/client.rs b/crates/storage-pg/src/oauth2/client.rs index 02e57a01a..60e1ebb54 100644 --- a/crates/storage-pg/src/oauth2/client.rs +++ b/crates/storage-pg/src/oauth2/client.rs @@ -554,6 +554,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { async fn upsert_static( &mut self, client_id: Ulid, + client_name: Option, client_auth_method: OAuthClientAuthenticationMethod, encrypted_client_secret: Option, jwks: Option, @@ -581,11 +582,12 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { , grant_type_device_code , token_endpoint_auth_method , jwks + , client_name , jwks_uri , is_static ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, TRUE) ON CONFLICT (oauth2_client_id) DO UPDATE SET encrypted_client_secret = EXCLUDED.encrypted_client_secret @@ -596,6 +598,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { , grant_type_device_code = EXCLUDED.grant_type_device_code , token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method , jwks = EXCLUDED.jwks + , client_name = EXCLUDED.client_name , jwks_uri = EXCLUDED.jwks_uri , is_static = TRUE "#, @@ -608,6 +611,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { true, client_auth_method, jwks_json, + client_name, jwks_uri.as_ref().map(Url::as_str), ) .traced() @@ -633,7 +637,7 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { GrantType::RefreshToken, GrantType::ClientCredentials, ], - client_name: None, + client_name, logo_uri: None, client_uri: None, policy_uri: None, diff --git a/crates/storage-pg/src/oauth2/device_code_grant.rs b/crates/storage-pg/src/oauth2/device_code_grant.rs index 409ab3ff9..ebed4d859 100644 --- a/crates/storage-pg/src/oauth2/device_code_grant.rs +++ b/crates/storage-pg/src/oauth2/device_code_grant.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session, UserAgent}; +use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session}; use mas_storage::{ Clock, oauth2::{OAuth2DeviceCodeGrantParams, OAuth2DeviceCodeGrantRepository}, @@ -132,7 +132,7 @@ impl TryFrom for DeviceCodeGrant { created_at, expires_at, ip_address, - user_agent: user_agent.map(UserAgent::parse), + user_agent, }) } } diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index 5968e625d..3f70fd5cc 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -24,7 +24,7 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::{AuthorizationCode, UserAgent}; + use mas_data_model::AuthorizationCode; use mas_storage::{ Clock, Pagination, clock::MockClock, @@ -138,6 +138,7 @@ mod tests { ResponseMode::Query, true, None, + None, ) .await .unwrap(); @@ -351,7 +352,7 @@ mod tests { assert!(session.user_agent.is_none()); let session = repo .oauth2_session() - .record_user_agent(session, UserAgent::parse("Mozilla/5.0".to_owned())) + .record_user_agent(session, "Mozilla/5.0".to_owned()) .await .unwrap(); assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0")); diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index 6b753e17d..d2fbd8130 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Client, Session, SessionState, User, UserAgent}; +use mas_data_model::{BrowserSession, Client, Session, SessionState, User}; use mas_storage::{ Clock, Page, Pagination, oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, @@ -55,6 +55,7 @@ struct OAuthSessionLookup { user_agent: Option, last_active_at: Option>, last_active_ip: Option, + human_name: Option, } impl TryFrom for Session { @@ -87,9 +88,10 @@ impl TryFrom for Session { user_id: value.user_id.map(Ulid::from), user_session_id: value.user_session_id.map(Ulid::from), scope, - user_agent: value.user_agent.map(UserAgent::parse), + user_agent: value.user_agent, last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, + human_name: value.human_name, }) } } @@ -195,6 +197,7 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { , user_agent , last_active_at , last_active_ip as "last_active_ip: IpAddr" + , human_name FROM oauth2_sessions WHERE oauth2_session_id = $1 @@ -270,6 +273,7 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { user_agent: None, last_active_at: None, last_active_ip: None, + human_name: None, }) } @@ -392,6 +396,10 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveIp)), OAuthSessionLookupIden::LastActiveIp, ) + .expr_as( + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)), + OAuthSessionLookupIden::HumanName, + ) .from(OAuth2Sessions::Table) .apply_filter(filter) .generate_pagination( @@ -445,13 +453,16 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { )] async fn record_batch_activity( &mut self, - activity: Vec<(Ulid, DateTime, Option)>, + mut activities: Vec<(Ulid, DateTime, Option)>, ) -> Result<(), Self::Error> { - let mut ids = Vec::with_capacity(activity.len()); - let mut last_activities = Vec::with_capacity(activity.len()); - let mut ips = Vec::with_capacity(activity.len()); + // 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 activity { + for (id, last_activity, ip) in activities { ids.push(Uuid::from(id)); last_activities.push(last_activity); ips.push(ip); @@ -490,14 +501,14 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { %session.id, %session.scope, client.id = %session.client_id, - session.user_agent = %user_agent.raw, + session.user_agent = user_agent, ), err, )] async fn record_user_agent( &mut self, mut session: Session, - user_agent: UserAgent, + user_agent: String, ) -> Result { let res = sqlx::query!( r#" @@ -518,4 +529,38 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { Ok(session) } + + #[tracing::instrument( + name = "repository.oauth2_session.set_human_name", + skip(self), + fields( + client.id = %session.client_id, + session.human_name = ?human_name, + ), + err, + )] + async fn set_human_name( + &mut self, + mut session: Session, + human_name: Option, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE oauth2_sessions + SET human_name = $2 + WHERE oauth2_session_id = $1 + "#, + Uuid::from(session.id), + human_name.as_deref(), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + session.human_name = human_name; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + Ok(session) + } } diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 901f1fd45..c6668c2e4 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -6,9 +6,11 @@ use std::ops::{Deref, DerefMut}; +use async_trait::async_trait; use futures_util::{FutureExt, TryFutureExt, future::BoxFuture}; use mas_storage::{ - BoxRepository, MapErr, Repository, RepositoryAccess, RepositoryError, RepositoryTransaction, + BoxRepository, BoxRepositoryFactory, MapErr, Repository, RepositoryAccess, RepositoryError, + RepositoryFactory, RepositoryTransaction, app_session::AppSessionRepository, compat::{ CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository, @@ -46,6 +48,7 @@ use crate::{ job::PgQueueJobRepository, schedule::PgQueueScheduleRepository, worker::PgQueueWorkerRepository, }, + telemetry::DB_CLIENT_CONNECTIONS_CREATE_TIME_HISTOGRAM, upstream_oauth2::{ PgUpstreamOAuthLinkRepository, PgUpstreamOAuthProviderRepository, PgUpstreamOAuthSessionRepository, @@ -57,6 +60,51 @@ use crate::{ }, }; +/// An implementation of the [`RepositoryFactory`] trait backed by a PostgreSQL +/// connection pool. +#[derive(Clone)] +pub struct PgRepositoryFactory { + pool: PgPool, +} + +impl PgRepositoryFactory { + /// Create a new [`PgRepositoryFactory`] from a PostgreSQL connection pool. + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Box the factory + #[must_use] + pub fn boxed(self) -> BoxRepositoryFactory { + Box::new(self) + } + + /// Get the underlying PostgreSQL connection pool + #[must_use] + pub fn pool(&self) -> PgPool { + self.pool.clone() + } +} + +#[async_trait] +impl RepositoryFactory for PgRepositoryFactory { + async fn create(&self) -> Result { + let start = std::time::Instant::now(); + let repo = PgRepository::from_pool(&self.pool) + .await + .map_err(RepositoryError::from_error)? + .boxed(); + + // Measure the time it took to create the connection + let duration = start.elapsed(); + let duration_ms = duration.as_millis().try_into().unwrap_or(u64::MAX); + DB_CLIENT_CONNECTIONS_CREATE_TIME_HISTOGRAM.record(duration_ms, &[]); + + Ok(repo) + } +} + /// An implementation of the [`Repository`] trait backed by a PostgreSQL /// transaction. pub struct PgRepository> { diff --git a/crates/storage-pg/src/telemetry.rs b/crates/storage-pg/src/telemetry.rs new file mode 100644 index 000000000..93c74e74f --- /dev/null +++ b/crates/storage-pg/src/telemetry.rs @@ -0,0 +1,31 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::sync::LazyLock; + +use opentelemetry::{ + InstrumentationScope, + metrics::{Histogram, Meter}, +}; +use opentelemetry_semantic_conventions as semcov; + +static SCOPE: LazyLock = LazyLock::new(|| { + InstrumentationScope::builder(env!("CARGO_PKG_NAME")) + .with_version(env!("CARGO_PKG_VERSION")) + .with_schema_url(semcov::SCHEMA_URL) + .build() +}); + +static METER: LazyLock = + LazyLock::new(|| opentelemetry::global::meter_with_scope(SCOPE.clone())); + +pub(crate) static DB_CLIENT_CONNECTIONS_CREATE_TIME_HISTOGRAM: LazyLock> = + LazyLock::new(|| { + METER + .u64_histogram("db.client.connections.create_time") + .with_description("The time it took to create a new connection.") + .with_unit("ms") + .build() + }); diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index d802e9bdb..a5cda570b 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -76,6 +76,7 @@ mod tests { pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, ui_order: 0, }, ) @@ -107,7 +108,7 @@ mod tests { &provider, "some-state".to_owned(), None, - "some-nonce".to_owned(), + Some("some-nonce".to_owned()), ) .await .unwrap(); @@ -323,6 +324,7 @@ mod tests { pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, ui_order: 0, }, ) diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 2e5f7233f..879d7c658 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -70,6 +70,7 @@ struct ProviderLookup { pkce_mode: String, response_mode: Option, additional_parameters: Option>>, + forward_login_hint: bool, } impl TryFrom for UpstreamOAuthProvider { @@ -217,6 +218,7 @@ impl TryFrom for UpstreamOAuthProvider { pkce_mode, response_mode, additional_authorization_parameters, + forward_login_hint: value.forward_login_hint, }) } } @@ -274,7 +276,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, - additional_parameters as "additional_parameters: Json>" + additional_parameters as "additional_parameters: Json>", + forward_login_hint FROM upstream_oauth_providers WHERE upstream_oauth_provider_id = $1 "#, @@ -336,9 +339,10 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + forward_login_hint, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) "#, Uuid::from(id), params.issuer.as_deref(), @@ -375,6 +379,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.discovery_mode.as_str(), params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), + params.forward_login_hint, created_at, ) .traced() @@ -405,6 +410,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode: params.pkce_mode, response_mode: params.response_mode, additional_authorization_parameters: params.additional_authorization_parameters, + forward_login_hint: params.forward_login_hint, }) } @@ -517,11 +523,12 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, additional_parameters, + forward_login_hint, ui_order, 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) + $21, $22, $23, $24) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -546,6 +553,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode = EXCLUDED.pkce_mode, response_mode = EXCLUDED.response_mode, additional_parameters = EXCLUDED.additional_parameters, + forward_login_hint = EXCLUDED.forward_login_hint, ui_order = EXCLUDED.ui_order RETURNING created_at "#, @@ -585,6 +593,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), Json(¶ms.additional_authorization_parameters) as _, + params.forward_login_hint, params.ui_order, created_at, ) @@ -616,6 +625,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode: params.pkce_mode, response_mode: params.response_mode, additional_authorization_parameters: params.additional_authorization_parameters, + forward_login_hint: params.forward_login_hint, }) } @@ -826,6 +836,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::AdditionalParameters, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::ForwardLoginHint, + )), + ProviderLookupIden::ForwardLoginHint, + ) .from(UpstreamOAuthProviders::Table) .apply_filter(filter) .generate_pagination( @@ -918,7 +935,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, - additional_parameters as "additional_parameters: Json>" + additional_parameters as "additional_parameters: Json>", + forward_login_hint 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 d9cad86a7..594f3be4c 100644 --- a/crates/storage-pg/src/upstream_oauth2/session.rs +++ b/crates/storage-pg/src/upstream_oauth2/session.rs @@ -38,7 +38,7 @@ struct SessionLookup { upstream_oauth_link_id: Option, state: String, code_challenge_verifier: Option, - nonce: String, + nonce: Option, id_token: Option, userinfo: Option, created_at: DateTime, @@ -191,7 +191,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { upstream_oauth_provider: &UpstreamOAuthProvider, state_str: String, code_challenge_verifier: Option, - nonce: String, + nonce: Option, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); diff --git a/crates/storage-pg/src/user/recovery.rs b/crates/storage-pg/src/user/recovery.rs index d838b2531..bc108b52a 100644 --- a/crates/storage-pg/src/user/recovery.rs +++ b/crates/storage-pg/src/user/recovery.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use mas_data_model::{UserAgent, UserEmail, UserRecoverySession, UserRecoveryTicket}; +use mas_data_model::{UserEmail, UserRecoverySession, UserRecoveryTicket}; use mas_storage::{Clock, user::UserRecoveryRepository}; use rand::RngCore; use sqlx::PgConnection; @@ -45,7 +45,7 @@ impl From for UserRecoverySession { UserRecoverySession { id: row.user_recovery_session_id.into(), email: row.email, - user_agent: UserAgent::parse(row.user_agent), + user_agent: row.user_agent, ip_address: row.ip_address, locale: row.locale, created_at: row.created_at, @@ -127,7 +127,7 @@ impl UserRecoveryRepository for PgUserRecoveryRepository<'_> { db.query.text, user_recovery_session.id, user_recovery_session.email = email, - user_recovery_session.user_agent = &*user_agent, + user_recovery_session.user_agent = user_agent, user_recovery_session.ip_address = ip_address.map(|ip| ip.to_string()), ) )] @@ -136,7 +136,7 @@ impl UserRecoveryRepository for PgUserRecoveryRepository<'_> { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, email: String, - user_agent: UserAgent, + user_agent: String, ip_address: Option, locale: String, ) -> Result { diff --git a/crates/storage-pg/src/user/registration.rs b/crates/storage-pg/src/user/registration.rs index 1aa2afe86..5d578ab79 100644 --- a/crates/storage-pg/src/user/registration.rs +++ b/crates/storage-pg/src/user/registration.rs @@ -7,9 +7,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{ - UserAgent, UserEmailAuthentication, UserRegistration, UserRegistrationPassword, -}; +use mas_data_model::{UserEmailAuthentication, UserRegistration, UserRegistrationPassword}; use mas_storage::{Clock, user::UserRegistrationRepository}; use rand::RngCore; use sqlx::PgConnection; @@ -53,7 +51,6 @@ impl TryFrom for UserRegistration { fn try_from(value: UserRegistrationLookup) -> Result { let id = Ulid::from(value.user_registration_id); - let user_agent = value.user_agent.map(UserAgent::parse); let password = match (value.hashed_password, value.hashed_password_version) { (Some(hashed_password), Some(version)) => { @@ -91,7 +88,7 @@ impl TryFrom for UserRegistration { Ok(UserRegistration { id, ip_address: value.ip_address, - user_agent, + user_agent: value.user_agent, post_auth_action: value.post_auth_action, username: value.username, display_name: value.display_name, @@ -162,7 +159,7 @@ impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { clock: &dyn Clock, username: String, ip_address: Option, - user_agent: Option, + user_agent: Option, post_auth_action: Option, ) -> Result { let created_at = clock.now(); @@ -394,7 +391,7 @@ impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { mod tests { use std::net::{IpAddr, Ipv4Addr}; - use mas_data_model::{UserAgent, UserRegistrationPassword}; + use mas_data_model::UserRegistrationPassword; use mas_storage::{Clock, clock::MockClock}; use rand::SeedableRng; use rand_chacha::ChaChaRng; @@ -487,16 +484,13 @@ mod tests { &clock, "alice".to_owned(), Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), - Some(UserAgent::parse("Mozilla/5.0".to_owned())), + Some("Mozilla/5.0".to_owned()), Some(serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"})), ) .await .unwrap(); - assert_eq!( - registration.user_agent, - Some(UserAgent::parse("Mozilla/5.0".to_owned())) - ); + 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))) diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index ce027afc0..3bea6781c 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ Authentication, AuthenticationMethod, BrowserSession, Password, - UpstreamOAuthAuthorizationSession, User, UserAgent, + UpstreamOAuthAuthorizationSession, User, }; use mas_storage::{ Clock, Page, Pagination, @@ -83,7 +83,7 @@ impl TryFrom for BrowserSession { user, created_at: value.user_session_created_at, finished_at: value.user_session_finished_at, - user_agent: value.user_session_user_agent.map(UserAgent::parse), + user_agent: value.user_session_user_agent, last_active_at: value.user_session_last_active_at, last_active_ip: value.user_session_last_active_ip, }) @@ -208,7 +208,7 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, user: &User, - user_agent: Option, + user_agent: Option, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -564,13 +564,16 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { )] async fn record_batch_activity( &mut self, - activity: Vec<(Ulid, DateTime, Option)>, + mut activities: Vec<(Ulid, DateTime, Option)>, ) -> Result<(), Self::Error> { - let mut ids = Vec::with_capacity(activity.len()); - let mut last_activities = Vec::with_capacity(activity.len()); - let mut ips = Vec::with_capacity(activity.len()); + // 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 activity { + for (id, last_activity, ip) in activities { ids.push(Uuid::from(id)); last_activities.push(last_activity); ips.push(ip); diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index 81a417fa6..e935e986b 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User, UserAgent}; +use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User}; use rand_core::RngCore; use ulid::Ulid; @@ -215,10 +215,13 @@ pub trait CompatSessionRepository: Send + Sync { /// * `device`: The device ID of this session /// * `browser_session`: The browser session which created this session /// * `is_synapse_admin`: Whether the session is a synapse admin session + /// * `human_name`: The human-readable name of the session provided by the + /// client or the user /// /// # Errors /// /// Returns [`Self::Error`] if the underlying repository fails + #[expect(clippy::too_many_arguments)] async fn add( &mut self, rng: &mut (dyn RngCore + Send), @@ -227,6 +230,7 @@ pub trait CompatSessionRepository: Send + Sync { device: Device, browser_session: Option<&BrowserSession>, is_synapse_admin: bool, + human_name: Option, ) -> Result; /// End a compat session @@ -322,7 +326,23 @@ pub trait CompatSessionRepository: Send + Sync { async fn record_user_agent( &mut self, compat_session: CompatSession, - user_agent: UserAgent, + user_agent: String, + ) -> Result; + + /// Set the human name of a compat session + /// + /// # Parameters + /// + /// * `compat_session`: The compat session to set the human name for + /// * `human_name`: The human name to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn set_human_name( + &mut self, + compat_session: CompatSession, + human_name: Option, ) -> Result; } @@ -337,6 +357,7 @@ repository_impl!(CompatSessionRepository: device: Device, browser_session: Option<&BrowserSession>, is_synapse_admin: bool, + human_name: Option, ) -> Result; async fn finish( @@ -367,6 +388,12 @@ repository_impl!(CompatSessionRepository: async fn record_user_agent( &mut self, compat_session: CompatSession, - user_agent: UserAgent, + user_agent: String, + ) -> Result; + + async fn set_human_name( + &mut self, + compat_session: CompatSession, + human_name: Option, ) -> Result; ); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 923113a6a..07d8bd97c 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -128,7 +128,8 @@ pub use self::{ clock::{Clock, SystemClock}, pagination::{Page, Pagination}, repository::{ - BoxRepository, Repository, RepositoryAccess, RepositoryError, RepositoryTransaction, + BoxRepository, BoxRepositoryFactory, Repository, RepositoryAccess, RepositoryError, + RepositoryFactory, RepositoryTransaction, }, utils::{BoxClock, BoxRng, MapErr}, }; diff --git a/crates/storage/src/oauth2/authorization_grant.rs b/crates/storage/src/oauth2/authorization_grant.rs index 7724ace87..cb4802a92 100644 --- a/crates/storage/src/oauth2/authorization_grant.rs +++ b/crates/storage/src/oauth2/authorization_grant.rs @@ -39,6 +39,8 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync { /// * `response_type_id_token`: Whether the `id_token` `response_type` was /// requested /// * `login_hint`: The login_hint the client sent, if set + /// * `locale`: The locale the detected when the user asked for the + /// authorization grant /// /// # Errors /// @@ -57,6 +59,7 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync { response_mode: ResponseMode, response_type_id_token: bool, login_hint: Option, + locale: Option, ) -> Result; /// Lookup an authorization grant by its ID @@ -140,6 +143,7 @@ repository_impl!(OAuth2AuthorizationGrantRepository: response_mode: ResponseMode, response_type_id_token: bool, login_hint: Option, + locale: Option, ) -> Result; async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; diff --git a/crates/storage/src/oauth2/client.rs b/crates/storage/src/oauth2/client.rs index aa5a82a2a..33b92d189 100644 --- a/crates/storage/src/oauth2/client.rs +++ b/crates/storage/src/oauth2/client.rs @@ -157,6 +157,7 @@ pub trait OAuth2ClientRepository: Send + Sync { async fn upsert_static( &mut self, client_id: Ulid, + client_name: Option, client_auth_method: OAuthClientAuthenticationMethod, encrypted_client_secret: Option, jwks: Option, @@ -237,6 +238,7 @@ repository_impl!(OAuth2ClientRepository: async fn upsert_static( &mut self, client_id: Ulid, + client_name: Option, client_auth_method: OAuthClientAuthenticationMethod, encrypted_client_secret: Option, jwks: Option, diff --git a/crates/storage/src/oauth2/device_code_grant.rs b/crates/storage/src/oauth2/device_code_grant.rs index 9a85a3a75..762e854cc 100644 --- a/crates/storage/src/oauth2/device_code_grant.rs +++ b/crates/storage/src/oauth2/device_code_grant.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::Duration; -use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session, UserAgent}; +use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; @@ -36,7 +36,7 @@ pub struct OAuth2DeviceCodeGrantParams<'a> { pub ip_address: Option, /// The user agent from which the request was made - pub user_agent: Option, + pub user_agent: Option, } /// An [`OAuth2DeviceCodeGrantRepository`] helps interacting with diff --git a/crates/storage/src/oauth2/session.rs b/crates/storage/src/oauth2/session.rs index d53eaa85c..07f91a2b0 100644 --- a/crates/storage/src/oauth2/session.rs +++ b/crates/storage/src/oauth2/session.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Client, Device, Session, User, UserAgent}; +use mas_data_model::{BrowserSession, Client, Device, Session, User}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; @@ -428,7 +428,19 @@ pub trait OAuth2SessionRepository: Send + Sync { async fn record_user_agent( &mut self, session: Session, - user_agent: UserAgent, + user_agent: String, + ) -> Result; + + /// Set the human name of a [`Session`] + /// + /// # Parameters + /// + /// * `session`: The [`Session`] to set the human name for + /// * `human_name`: The human name to set + async fn set_human_name( + &mut self, + session: Session, + human_name: Option, ) -> Result; } @@ -487,6 +499,12 @@ repository_impl!(OAuth2SessionRepository: async fn record_user_agent( &mut self, session: Session, - user_agent: UserAgent, + user_agent: String, + ) -> Result; + + async fn set_human_name( + &mut self, + session: Session, + human_name: Option, ) -> Result; ); diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 2f051493c..93c43d469 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -4,6 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +use async_trait::async_trait; use futures_util::future::BoxFuture; use thiserror::Error; @@ -29,6 +30,18 @@ use crate::{ }, }; +/// A [`RepositoryFactory`] is a factory that can create a [`BoxRepository`] +// XXX(quenting): this could be generic over the repository type, but it's annoying to make it +// dyn-safe +#[async_trait] +pub trait RepositoryFactory { + /// Create a new [`BoxRepository`] + async fn create(&self) -> Result; +} + +/// A type-erased [`RepositoryFactory`] +pub type BoxRepositoryFactory = Box; + /// A [`Repository`] helps interacting with the underlying storage backend. pub trait Repository: RepositoryAccess + RepositoryTransaction + Send diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index 673050a8f..ac6553b88 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -96,6 +96,9 @@ pub struct UpstreamOAuthProviderParams { /// Additional parameters to include in the authorization request pub additional_authorization_parameters: Vec<(String, String)>, + /// Whether to forward the login hint to the upstream provider. + pub forward_login_hint: bool, + /// The position of the provider in the UI pub ui_order: i32, } diff --git a/crates/storage/src/upstream_oauth2/session.rs b/crates/storage/src/upstream_oauth2/session.rs index d87ee7d3a..c563fce5e 100644 --- a/crates/storage/src/upstream_oauth2/session.rs +++ b/crates/storage/src/upstream_oauth2/session.rs @@ -48,7 +48,7 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync { /// upstream OAuth provider /// * `code_challenge_verifier`: the code challenge verifier used in this /// session, if PKCE is being used - /// * `nonce`: the `nonce` used in this session + /// * `nonce`: the `nonce` used in this session if in OIDC mode /// /// # Errors /// @@ -60,7 +60,7 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync { upstream_oauth_provider: &UpstreamOAuthProvider, state: String, code_challenge_verifier: Option, - nonce: String, + nonce: Option, ) -> Result; /// Mark a session as completed and associate the given link @@ -122,7 +122,7 @@ repository_impl!(UpstreamOAuthSessionRepository: upstream_oauth_provider: &UpstreamOAuthProvider, state: String, code_challenge_verifier: Option, - nonce: String, + nonce: Option, ) -> Result; async fn complete_with_link( diff --git a/crates/storage/src/user/recovery.rs b/crates/storage/src/user/recovery.rs index 05f2d9333..a5361e795 100644 --- a/crates/storage/src/user/recovery.rs +++ b/crates/storage/src/user/recovery.rs @@ -7,7 +7,7 @@ use std::net::IpAddr; use async_trait::async_trait; -use mas_data_model::{UserAgent, UserEmail, UserRecoverySession, UserRecoveryTicket}; +use mas_data_model::{UserEmail, UserRecoverySession, UserRecoveryTicket}; use rand_core::RngCore; use ulid::Ulid; @@ -59,7 +59,7 @@ pub trait UserRecoveryRepository: Send + Sync { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, email: String, - user_agent: UserAgent, + user_agent: String, ip_address: Option, locale: String, ) -> Result; @@ -131,7 +131,7 @@ repository_impl!(UserRecoveryRepository: rng: &mut (dyn RngCore + Send), clock: &dyn Clock, email: String, - user_agent: UserAgent, + user_agent: String, ip_address: Option, locale: String, ) -> Result; diff --git a/crates/storage/src/user/registration.rs b/crates/storage/src/user/registration.rs index 8bc4ddcb0..3932db622 100644 --- a/crates/storage/src/user/registration.rs +++ b/crates/storage/src/user/registration.rs @@ -6,7 +6,7 @@ use std::net::IpAddr; use async_trait::async_trait; -use mas_data_model::{UserAgent, UserEmailAuthentication, UserRegistration}; +use mas_data_model::{UserEmailAuthentication, UserRegistration}; use rand_core::RngCore; use ulid::Ulid; use url::Url; @@ -56,7 +56,7 @@ pub trait UserRegistrationRepository: Send + Sync { clock: &dyn Clock, username: String, ip_address: Option, - user_agent: Option, + user_agent: Option, post_auth_action: Option, ) -> Result; @@ -166,7 +166,7 @@ repository_impl!(UserRegistrationRepository: clock: &dyn Clock, username: String, ip_address: Option, - user_agent: Option, + user_agent: Option, post_auth_action: Option, ) -> Result; async fn set_display_name( diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index 355507530..2421ff009 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -9,7 +9,7 @@ use std::net::IpAddr; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ - Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, UserAgent, + Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, }; use rand_core::RngCore; use ulid::Ulid; @@ -151,7 +151,7 @@ pub trait BrowserSessionRepository: Send + Sync { rng: &mut (dyn RngCore + Send), clock: &dyn Clock, user: &User, - user_agent: Option, + user_agent: Option, ) -> Result; /// Finish a [`BrowserSession`] @@ -296,7 +296,7 @@ repository_impl!(BrowserSessionRepository: rng: &mut (dyn RngCore + Send), clock: &dyn Clock, user: &User, - user_agent: Option, + user_agent: Option, ) -> Result; async fn finish( &mut self, diff --git a/crates/syn2mas/.sqlx/query-026adeffc646b41ebc096bb874d110039b9a4a0425fd566e401f56ea215de0dd.json b/crates/syn2mas/.sqlx/query-026adeffc646b41ebc096bb874d110039b9a4a0425fd566e401f56ea215de0dd.json new file mode 100644 index 000000000..fa5f442ed --- /dev/null +++ b/crates/syn2mas/.sqlx/query-026adeffc646b41ebc096bb874d110039b9a4a0425fd566e401f56ea215de0dd.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__upstream_oauth_links\n (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "026adeffc646b41ebc096bb874d110039b9a4a0425fd566e401f56ea215de0dd" +} diff --git a/crates/syn2mas/.sqlx/query-08ad2855f0baaaed9d6af23c8bf035e9a087ff27b06e804464a432d93e5a25f1.json b/crates/syn2mas/.sqlx/query-08ad2855f0baaaed9d6af23c8bf035e9a087ff27b06e804464a432d93e5a25f1.json new file mode 100644 index 000000000..545389cb6 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-08ad2855f0baaaed9d6af23c8bf035e9a087ff27b06e804464a432d93e5a25f1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__user_emails\n (user_email_id, user_id, email, created_at, confirmed_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "08ad2855f0baaaed9d6af23c8bf035e9a087ff27b06e804464a432d93e5a25f1" +} diff --git a/crates/syn2mas/.sqlx/query-09db58b250c20ab9d1701653165233e5c9aabfdae1f0ee9b77c909b2bb2f3e25.json b/crates/syn2mas/.sqlx/query-09db58b250c20ab9d1701653165233e5c9aabfdae1f0ee9b77c909b2bb2f3e25.json new file mode 100644 index 000000000..97e8a07a0 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-09db58b250c20ab9d1701653165233e5c9aabfdae1f0ee9b77c909b2bb2f3e25.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__compat_sessions (\n compat_session_id, user_id,\n device_id, human_name,\n created_at, is_synapse_admin,\n last_active_at, last_active_ip,\n user_agent)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::UUID[],\n $3::TEXT[], $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[], $6::BOOLEAN[],\n $7::TIMESTAMP WITH TIME ZONE[], $8::INET[],\n $9::TEXT[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "TextArray", + "TextArray", + "TimestamptzArray", + "BoolArray", + "TimestamptzArray", + "InetArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "09db58b250c20ab9d1701653165233e5c9aabfdae1f0ee9b77c909b2bb2f3e25" +} diff --git a/crates/syn2mas/.sqlx/query-1d1004d0fb5939fbf30c1986b80b986b1b4864a778525d0b8b0ad6678aef3e9f.json b/crates/syn2mas/.sqlx/query-1d1004d0fb5939fbf30c1986b80b986b1b4864a778525d0b8b0ad6678aef3e9f.json new file mode 100644 index 000000000..c65dfb7a4 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-1d1004d0fb5939fbf30c1986b80b986b1b4864a778525d0b8b0ad6678aef3e9f.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__compat_refresh_tokens (\n compat_refresh_token_id,\n compat_session_id,\n compat_access_token_id,\n refresh_token,\n created_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::UUID[],\n $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "1d1004d0fb5939fbf30c1986b80b986b1b4864a778525d0b8b0ad6678aef3e9f" +} diff --git a/crates/syn2mas/.sqlx/query-204cf4811150a7fdeafa9373647a9cd62ac3c9e58155882858c6056e2ef6c30d.json b/crates/syn2mas/.sqlx/query-204cf4811150a7fdeafa9373647a9cd62ac3c9e58155882858c6056e2ef6c30d.json new file mode 100644 index 000000000..464dd9007 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-204cf4811150a7fdeafa9373647a9cd62ac3c9e58155882858c6056e2ef6c30d.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__user_unsupported_third_party_ids\n (user_id, medium, address, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TextArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "204cf4811150a7fdeafa9373647a9cd62ac3c9e58155882858c6056e2ef6c30d" +} diff --git a/crates/syn2mas/.sqlx/query-207b880ec2dd484ad05a7138ba485277958b66e4534561686c073e282fafaf2a.json b/crates/syn2mas/.sqlx/query-207b880ec2dd484ad05a7138ba485277958b66e4534561686c073e282fafaf2a.json new file mode 100644 index 000000000..79688d807 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-207b880ec2dd484ad05a7138ba485277958b66e4534561686c073e282fafaf2a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__users (\n user_id, username,\n created_at, locked_at,\n deactivated_at,\n can_request_admin, is_guest)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[],\n $6::BOOL[], $7::BOOL[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TextArray", + "TimestamptzArray", + "TimestamptzArray", + "TimestamptzArray", + "BoolArray", + "BoolArray" + ] + }, + "nullable": [] + }, + "hash": "207b880ec2dd484ad05a7138ba485277958b66e4534561686c073e282fafaf2a" +} diff --git a/crates/syn2mas/.sqlx/query-24f6ce6280dc6675ab1ebdde0c5e3db8ff7a686180d71052911879f186ed1c8e.json b/crates/syn2mas/.sqlx/query-24f6ce6280dc6675ab1ebdde0c5e3db8ff7a686180d71052911879f186ed1c8e.json new file mode 100644 index 000000000..d736336f2 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-24f6ce6280dc6675ab1ebdde0c5e3db8ff7a686180d71052911879f186ed1c8e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__user_passwords\n (user_password_id, user_id, hashed_password, created_at, version)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray", + "Int4Array" + ] + }, + "nullable": [] + }, + "hash": "24f6ce6280dc6675ab1ebdde0c5e3db8ff7a686180d71052911879f186ed1c8e" +} diff --git a/crates/syn2mas/.sqlx/query-396c97dbfbc932c73301daa7376e36f4ef03e1224a0a89a921e5a3d398a5d35c.json b/crates/syn2mas/.sqlx/query-396c97dbfbc932c73301daa7376e36f4ef03e1224a0a89a921e5a3d398a5d35c.json deleted file mode 100644 index 521e4facd..000000000 --- a/crates/syn2mas/.sqlx/query-396c97dbfbc932c73301daa7376e36f4ef03e1224a0a89a921e5a3d398a5d35c.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__compat_sessions (\n compat_session_id, user_id,\n device_id, human_name,\n created_at, is_synapse_admin,\n last_active_at, last_active_ip,\n user_agent)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::UUID[],\n $3::TEXT[], $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[], $6::BOOLEAN[],\n $7::TIMESTAMP WITH TIME ZONE[], $8::INET[],\n $9::TEXT[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "UuidArray", - "TextArray", - "TextArray", - "TimestamptzArray", - "BoolArray", - "TimestamptzArray", - "InetArray", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "396c97dbfbc932c73301daa7376e36f4ef03e1224a0a89a921e5a3d398a5d35c" -} diff --git a/crates/syn2mas/.sqlx/query-86b2b02fbb6350100d794e4d0fa3c67bf00fd3e411f769b9f25dec27428489ed.json b/crates/syn2mas/.sqlx/query-86b2b02fbb6350100d794e4d0fa3c67bf00fd3e411f769b9f25dec27428489ed.json new file mode 100644 index 000000000..dd8a8e306 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-86b2b02fbb6350100d794e4d0fa3c67bf00fd3e411f769b9f25dec27428489ed.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__compat_access_tokens (\n compat_access_token_id,\n compat_session_id,\n access_token,\n created_at,\n expires_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::TEXT[],\n $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "86b2b02fbb6350100d794e4d0fa3c67bf00fd3e411f769b9f25dec27428489ed" +} diff --git a/crates/syn2mas/.sqlx/query-88975196c4c174d464b33aa015ce5d8cac3836701fc24922f4f0e8b98d330796.json b/crates/syn2mas/.sqlx/query-88975196c4c174d464b33aa015ce5d8cac3836701fc24922f4f0e8b98d330796.json deleted file mode 100644 index cb251624d..000000000 --- a/crates/syn2mas/.sqlx/query-88975196c4c174d464b33aa015ce5d8cac3836701fc24922f4f0e8b98d330796.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__compat_refresh_tokens (\n compat_refresh_token_id,\n compat_session_id,\n compat_access_token_id,\n refresh_token,\n created_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::UUID[],\n $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "UuidArray", - "UuidArray", - "TextArray", - "TimestamptzArray" - ] - }, - "nullable": [] - }, - "hash": "88975196c4c174d464b33aa015ce5d8cac3836701fc24922f4f0e8b98d330796" -} diff --git a/crates/syn2mas/.sqlx/query-b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b.json b/crates/syn2mas/.sqlx/query-b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b.json deleted file mode 100644 index b44dfc605..000000000 --- a/crates/syn2mas/.sqlx/query-b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__user_unsupported_third_party_ids\n (user_id, medium, address, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "TextArray", - "TextArray", - "TimestamptzArray" - ] - }, - "nullable": [] - }, - "hash": "b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b" -} diff --git a/crates/syn2mas/.sqlx/query-c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425.json b/crates/syn2mas/.sqlx/query-c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425.json deleted file mode 100644 index efa2c4d24..000000000 --- a/crates/syn2mas/.sqlx/query-c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__user_passwords\n (user_password_id, user_id, hashed_password, created_at, version)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "UuidArray", - "TextArray", - "TimestamptzArray", - "Int4Array" - ] - }, - "nullable": [] - }, - "hash": "c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425" -} diff --git a/crates/syn2mas/.sqlx/query-d55adc78a0c222e19688e6ac810ad976c3a0ff238046872b17d3f412beda62c7.json b/crates/syn2mas/.sqlx/query-d55adc78a0c222e19688e6ac810ad976c3a0ff238046872b17d3f412beda62c7.json deleted file mode 100644 index eb406d23b..000000000 --- a/crates/syn2mas/.sqlx/query-d55adc78a0c222e19688e6ac810ad976c3a0ff238046872b17d3f412beda62c7.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__compat_access_tokens (\n compat_access_token_id,\n compat_session_id,\n access_token,\n created_at,\n expires_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::TEXT[],\n $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "UuidArray", - "TextArray", - "TimestamptzArray", - "TimestamptzArray" - ] - }, - "nullable": [] - }, - "hash": "d55adc78a0c222e19688e6ac810ad976c3a0ff238046872b17d3f412beda62c7" -} diff --git a/crates/syn2mas/.sqlx/query-d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32.json b/crates/syn2mas/.sqlx/query-d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32.json deleted file mode 100644 index f6ac32781..000000000 --- a/crates/syn2mas/.sqlx/query-d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__upstream_oauth_links\n (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "UuidArray", - "UuidArray", - "TextArray", - "TimestamptzArray" - ] - }, - "nullable": [] - }, - "hash": "d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32" -} diff --git a/crates/syn2mas/.sqlx/query-dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00.json b/crates/syn2mas/.sqlx/query-dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00.json deleted file mode 100644 index cf89130f9..000000000 --- a/crates/syn2mas/.sqlx/query-dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__user_emails\n (user_email_id, user_id, email, created_at, confirmed_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "UuidArray", - "TextArray", - "TimestamptzArray" - ] - }, - "nullable": [] - }, - "hash": "dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00" -} diff --git a/crates/syn2mas/.sqlx/query-f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1.json b/crates/syn2mas/.sqlx/query-f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1.json deleted file mode 100644 index 66979a67e..000000000 --- a/crates/syn2mas/.sqlx/query-f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO syn2mas__users (\n user_id, username,\n created_at, locked_at,\n deactivated_at,\n can_request_admin, is_guest)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[],\n $6::BOOL[], $7::BOOL[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "TextArray", - "TimestamptzArray", - "TimestamptzArray", - "TimestamptzArray", - "BoolArray", - "BoolArray" - ] - }, - "nullable": [] - }, - "hash": "f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1" -} diff --git a/crates/syn2mas/Cargo.toml b/crates/syn2mas/Cargo.toml index 0e82867ce..61e7ac2d5 100644 --- a/crates/syn2mas/Cargo.toml +++ b/crates/syn2mas/Cargo.toml @@ -16,6 +16,7 @@ bitflags.workspace = true camino.workspace = true figment.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true thiserror-ext.workspace = true tokio.workspace = true @@ -26,6 +27,7 @@ compact_str.workspace = true tracing.workspace = true futures-util = "0.3.31" rustc-hash = "2.1.1" +url.workspace = true rand.workspace = true rand_chacha = "0.3.1" @@ -33,7 +35,9 @@ uuid = "1.16.0" ulid = { workspace = true, features = ["uuid"] } mas-config.workspace = true +mas-iana.workspace = true mas-storage.workspace = true +oauth2-types.workspace = true opentelemetry.workspace = true opentelemetry-semantic-conventions.workspace = true diff --git a/crates/syn2mas/src/mas_writer/checks.rs b/crates/syn2mas/src/mas_writer/checks.rs index d5b51b510..288156d8c 100644 --- a/crates/syn2mas/src/mas_writer/checks.rs +++ b/crates/syn2mas/src/mas_writer/checks.rs @@ -1,4 +1,4 @@ -// 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. @@ -16,10 +16,12 @@ use super::{MAS_TABLES_AFFECTED_BY_MIGRATION, is_syn2mas_in_progress, locking::L #[derive(Debug, Error, ContextInto)] pub enum Error { - #[error("the MAS database is not empty: rows found in at least `{table}`")] + #[error( + "The MAS database is not empty: rows found in at least `{table}`. Please drop and recreate the database, then try again." + )] MasDatabaseNotEmpty { table: &'static str }, - #[error("query against {table} failed — is this actually a MAS database?")] + #[error("Query against {table} failed — is this actually a MAS database?")] MaybeNotMas { #[source] source: sqlx::Error, @@ -29,7 +31,7 @@ pub enum Error { #[error(transparent)] Sqlx(#[from] sqlx::Error), - #[error("unable to check if syn2mas is already in progress")] + #[error("Unable to check if syn2mas is already in progress")] UnableToCheckInProgress(#[source] super::Error), } diff --git a/crates/syn2mas/src/mas_writer/constraint_pausing.rs b/crates/syn2mas/src/mas_writer/constraint_pausing.rs index 36783215f..49fd4a8e3 100644 --- a/crates/syn2mas/src/mas_writer/constraint_pausing.rs +++ b/crates/syn2mas/src/mas_writer/constraint_pausing.rs @@ -1,4 +1,4 @@ -// 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. @@ -123,7 +123,6 @@ pub async fn restore_constraint( table_name, definition, } = &constraint; - info!("rebuilding constraint {name}"); sqlx::query(&format!( "ALTER TABLE {table_name} ADD CONSTRAINT {name} {definition};" diff --git a/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql b/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql index 7d1e98bc8..9da09b174 100644 --- a/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql +++ b/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql @@ -1,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO upstream_oauth_providers ( upstream_oauth_provider_id, diff --git a/crates/syn2mas/src/mas_writer/locking.rs b/crates/syn2mas/src/mas_writer/locking.rs index 031ca9ac3..8200924d4 100644 --- a/crates/syn2mas/src/mas_writer/locking.rs +++ b/crates/syn2mas/src/mas_writer/locking.rs @@ -1,4 +1,4 @@ -// 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. diff --git a/crates/syn2mas/src/mas_writer/mod.rs b/crates/syn2mas/src/mas_writer/mod.rs index 865bf02fe..f36851dfd 100644 --- a/crates/syn2mas/src/mas_writer/mod.rs +++ b/crates/syn2mas/src/mas_writer/mod.rs @@ -1,4 +1,4 @@ -// 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. @@ -22,7 +22,7 @@ use sqlx::{Executor, PgConnection, query, query_as}; use thiserror::Error; use thiserror_ext::{Construct, ContextInto}; use tokio::sync::mpsc::{self, Receiver, Sender}; -use tracing::{Instrument, Level, error, info, warn}; +use tracing::{Instrument, error, info, warn}; use uuid::{NonNilUuid, Uuid}; use self::{ @@ -46,7 +46,7 @@ pub enum Error { }, #[error("writer connection pool shut down due to error")] - #[allow(clippy::enum_variant_names)] + #[expect(clippy::enum_variant_names)] WriterConnectionPoolError, #[error("inconsistent database: {0}")] @@ -114,7 +114,6 @@ impl WriterConnectionPool { where F: for<'conn> FnOnce(&'conn mut PgConnection) -> BoxFuture<'conn, Result<(), Error>> + Send - + Sync + 'static, { match self.connection_rx.recv().await { @@ -243,6 +242,7 @@ impl FinishCheckerHandle { pub struct MasWriter { conn: LockedMasDatabase, writer_pool: WriterConnectionPool, + dry_run: bool, indices_to_restore: Vec, constraints_to_restore: Vec, @@ -250,6 +250,13 @@ pub struct MasWriter { write_buffer_finish_checker: FinishChecker, } +pub trait WriteBatch: Send + Sync + Sized + 'static { + fn write_batch( + conn: &mut PgConnection, + batch: Vec, + ) -> impl Future> + Send; +} + pub struct MasNewUser { pub user_id: NonNilUuid, pub username: String, @@ -263,6 +270,70 @@ pub struct MasNewUser { pub is_guest: bool, } +impl WriteBatch for MasNewUser { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + // `UNNEST` is a fast way to do bulk inserts, as it lets us send multiple rows + // in one statement without having to change the statement + // SQL thus altering the query plan. See . + // In the future we could consider using sqlx's support for `PgCopyIn` / the + // `COPY FROM STDIN` statement, which is allegedly the best + // for insert performance, but is less simple to encode. + let mut user_ids: Vec = Vec::with_capacity(batch.len()); + let mut usernames: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + let mut locked_ats: Vec>> = Vec::with_capacity(batch.len()); + let mut deactivated_ats: Vec>> = Vec::with_capacity(batch.len()); + let mut can_request_admins: Vec = Vec::with_capacity(batch.len()); + let mut is_guests: Vec = Vec::with_capacity(batch.len()); + for MasNewUser { + user_id, + username, + created_at, + locked_at, + deactivated_at, + can_request_admin, + is_guest, + } in batch + { + user_ids.push(user_id.get()); + usernames.push(username); + created_ats.push(created_at); + locked_ats.push(locked_at); + deactivated_ats.push(deactivated_at); + can_request_admins.push(can_request_admin); + is_guests.push(is_guest); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__users ( + user_id, username, + created_at, locked_at, + deactivated_at, + can_request_admin, is_guest) + SELECT * FROM UNNEST( + $1::UUID[], $2::TEXT[], + $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[], + $5::TIMESTAMP WITH TIME ZONE[], + $6::BOOL[], $7::BOOL[]) + "#, + &user_ids[..], + &usernames[..], + &created_ats[..], + // We need to override the typing for arrays of optionals (sqlx limitation) + &locked_ats[..] as &[Option>], + &deactivated_ats[..] as &[Option>], + &can_request_admins[..], + &is_guests[..], + ) + .execute(&mut *conn) + .await + .into_database("writing users to MAS")?; + + Ok(()) + } +} + pub struct MasNewUserPassword { pub user_password_id: Uuid, pub user_id: NonNilUuid, @@ -270,6 +341,44 @@ pub struct MasNewUserPassword { pub created_at: DateTime, } +impl WriteBatch for MasNewUserPassword { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut user_password_ids: Vec = Vec::with_capacity(batch.len()); + let mut user_ids: Vec = Vec::with_capacity(batch.len()); + let mut hashed_passwords: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + let mut versions: Vec = Vec::with_capacity(batch.len()); + for MasNewUserPassword { + user_password_id, + user_id, + hashed_password, + created_at, + } in batch + { + user_password_ids.push(user_password_id); + user_ids.push(user_id.get()); + hashed_passwords.push(hashed_password); + created_ats.push(created_at); + versions.push(MIGRATED_PASSWORD_VERSION.into()); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__user_passwords + (user_password_id, user_id, hashed_password, created_at, version) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[]) + "#, + &user_password_ids[..], + &user_ids[..], + &hashed_passwords[..], + &created_ats[..], + &versions[..], + ).execute(&mut *conn).await.into_database("writing users to MAS")?; + + Ok(()) + } +} + pub struct MasNewEmailThreepid { pub user_email_id: Uuid, pub user_id: NonNilUuid, @@ -277,6 +386,44 @@ pub struct MasNewEmailThreepid { pub created_at: DateTime, } +impl WriteBatch for MasNewEmailThreepid { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut user_email_ids: Vec = Vec::with_capacity(batch.len()); + let mut user_ids: Vec = Vec::with_capacity(batch.len()); + let mut emails: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + + for MasNewEmailThreepid { + user_email_id, + user_id, + email, + created_at, + } in batch + { + user_email_ids.push(user_email_id); + user_ids.push(user_id.get()); + emails.push(email); + created_ats.push(created_at); + } + + // `confirmed_at` is going to get removed in a future MAS release, + // so just populate with `created_at` + sqlx::query!( + r#" + INSERT INTO syn2mas__user_emails + (user_email_id, user_id, email, created_at, confirmed_at) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[]) + "#, + &user_email_ids[..], + &user_ids[..], + &emails[..], + &created_ats[..], + ).execute(&mut *conn).await.into_database("writing emails to MAS")?; + + Ok(()) + } +} + pub struct MasNewUnsupportedThreepid { pub user_id: NonNilUuid, pub medium: String, @@ -284,6 +431,45 @@ pub struct MasNewUnsupportedThreepid { pub created_at: DateTime, } +impl WriteBatch for MasNewUnsupportedThreepid { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut user_ids: Vec = Vec::with_capacity(batch.len()); + let mut mediums: Vec = Vec::with_capacity(batch.len()); + let mut addresses: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + + for MasNewUnsupportedThreepid { + user_id, + medium, + address, + created_at, + } in batch + { + user_ids.push(user_id.get()); + mediums.push(medium); + addresses.push(address); + created_ats.push(created_at); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__user_unsupported_third_party_ids + (user_id, medium, address, created_at) + SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[]) + "#, + &user_ids[..], + &mediums[..], + &addresses[..], + &created_ats[..], + ) + .execute(&mut *conn) + .await + .into_database("writing unsupported threepids to MAS")?; + + Ok(()) + } +} + pub struct MasNewUpstreamOauthLink { pub link_id: Uuid, pub user_id: NonNilUuid, @@ -292,6 +478,46 @@ pub struct MasNewUpstreamOauthLink { pub created_at: DateTime, } +impl WriteBatch for MasNewUpstreamOauthLink { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut link_ids: Vec = Vec::with_capacity(batch.len()); + let mut user_ids: Vec = Vec::with_capacity(batch.len()); + let mut upstream_provider_ids: Vec = Vec::with_capacity(batch.len()); + let mut subjects: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + + for MasNewUpstreamOauthLink { + link_id, + user_id, + upstream_provider_id, + subject, + created_at, + } in batch + { + link_ids.push(link_id); + user_ids.push(user_id.get()); + upstream_provider_ids.push(upstream_provider_id); + subjects.push(subject); + created_ats.push(created_at); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__upstream_oauth_links + (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[]) + "#, + &link_ids[..], + &user_ids[..], + &upstream_provider_ids[..], + &subjects[..], + &created_ats[..], + ).execute(&mut *conn).await.into_database("writing unsupported threepids to MAS")?; + + Ok(()) + } +} + pub struct MasNewCompatSession { pub session_id: Uuid, pub user_id: NonNilUuid, @@ -304,6 +530,75 @@ pub struct MasNewCompatSession { pub user_agent: Option, } +impl WriteBatch for MasNewCompatSession { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut session_ids: Vec = Vec::with_capacity(batch.len()); + let mut user_ids: Vec = Vec::with_capacity(batch.len()); + let mut device_ids: Vec> = Vec::with_capacity(batch.len()); + let mut human_names: Vec> = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + let mut is_synapse_admins: Vec = Vec::with_capacity(batch.len()); + let mut last_active_ats: Vec>> = Vec::with_capacity(batch.len()); + let mut last_active_ips: Vec> = Vec::with_capacity(batch.len()); + let mut user_agents: Vec> = Vec::with_capacity(batch.len()); + + for MasNewCompatSession { + session_id, + user_id, + device_id, + human_name, + created_at, + is_synapse_admin, + last_active_at, + last_active_ip, + user_agent, + } in batch + { + session_ids.push(session_id); + user_ids.push(user_id.get()); + device_ids.push(device_id); + human_names.push(human_name); + created_ats.push(created_at); + is_synapse_admins.push(is_synapse_admin); + last_active_ats.push(last_active_at); + last_active_ips.push(last_active_ip); + user_agents.push(user_agent); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__compat_sessions ( + compat_session_id, user_id, + device_id, human_name, + created_at, is_synapse_admin, + last_active_at, last_active_ip, + user_agent) + SELECT * FROM UNNEST( + $1::UUID[], $2::UUID[], + $3::TEXT[], $4::TEXT[], + $5::TIMESTAMP WITH TIME ZONE[], $6::BOOLEAN[], + $7::TIMESTAMP WITH TIME ZONE[], $8::INET[], + $9::TEXT[]) + "#, + &session_ids[..], + &user_ids[..], + &device_ids[..] as &[Option], + &human_names[..] as &[Option], + &created_ats[..], + &is_synapse_admins[..], + // We need to override the typing for arrays of optionals (sqlx limitation) + &last_active_ats[..] as &[Option>], + &last_active_ips[..] as &[Option], + &user_agents[..] as &[Option], + ) + .execute(&mut *conn) + .await + .into_database("writing compat sessions to MAS")?; + + Ok(()) + } +} + pub struct MasNewCompatAccessToken { pub token_id: Uuid, pub session_id: Uuid, @@ -312,6 +607,59 @@ pub struct MasNewCompatAccessToken { pub expires_at: Option>, } +impl WriteBatch for MasNewCompatAccessToken { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut token_ids: Vec = Vec::with_capacity(batch.len()); + let mut session_ids: Vec = Vec::with_capacity(batch.len()); + let mut access_tokens: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + let mut expires_ats: Vec>> = Vec::with_capacity(batch.len()); + + for MasNewCompatAccessToken { + token_id, + session_id, + access_token, + created_at, + expires_at, + } in batch + { + token_ids.push(token_id); + session_ids.push(session_id); + access_tokens.push(access_token); + created_ats.push(created_at); + expires_ats.push(expires_at); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__compat_access_tokens ( + compat_access_token_id, + compat_session_id, + access_token, + created_at, + expires_at) + SELECT * FROM UNNEST( + $1::UUID[], + $2::UUID[], + $3::TEXT[], + $4::TIMESTAMP WITH TIME ZONE[], + $5::TIMESTAMP WITH TIME ZONE[]) + "#, + &token_ids[..], + &session_ids[..], + &access_tokens[..], + &created_ats[..], + // We need to override the typing for arrays of optionals (sqlx limitation) + &expires_ats[..] as &[Option>], + ) + .execute(&mut *conn) + .await + .into_database("writing compat access tokens to MAS")?; + + Ok(()) + } +} + pub struct MasNewCompatRefreshToken { pub refresh_token_id: Uuid, pub session_id: Uuid, @@ -320,6 +668,58 @@ pub struct MasNewCompatRefreshToken { pub created_at: DateTime, } +impl WriteBatch for MasNewCompatRefreshToken { + async fn write_batch(conn: &mut PgConnection, batch: Vec) -> Result<(), Error> { + let mut refresh_token_ids: Vec = Vec::with_capacity(batch.len()); + let mut session_ids: Vec = Vec::with_capacity(batch.len()); + let mut access_token_ids: Vec = Vec::with_capacity(batch.len()); + let mut refresh_tokens: Vec = Vec::with_capacity(batch.len()); + let mut created_ats: Vec> = Vec::with_capacity(batch.len()); + + for MasNewCompatRefreshToken { + refresh_token_id, + session_id, + access_token_id, + refresh_token, + created_at, + } in batch + { + refresh_token_ids.push(refresh_token_id); + session_ids.push(session_id); + access_token_ids.push(access_token_id); + refresh_tokens.push(refresh_token); + created_ats.push(created_at); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__compat_refresh_tokens ( + compat_refresh_token_id, + compat_session_id, + compat_access_token_id, + refresh_token, + created_at) + SELECT * FROM UNNEST( + $1::UUID[], + $2::UUID[], + $3::UUID[], + $4::TEXT[], + $5::TIMESTAMP WITH TIME ZONE[]) + "#, + &refresh_token_ids[..], + &session_ids[..], + &access_token_ids[..], + &refresh_tokens[..], + &created_ats[..], + ) + .execute(&mut *conn) + .await + .into_database("writing compat refresh tokens to MAS")?; + + Ok(()) + } +} + /// The 'version' of the password hashing scheme used for passwords when they /// are migrated from Synapse to MAS. /// This is version 1, as in the previous syn2mas script. @@ -390,11 +790,11 @@ impl MasWriter { /// Errors are returned in the following conditions: /// /// - If the database connection experiences an error. - #[allow(clippy::missing_panics_doc)] // not real #[tracing::instrument(name = "syn2mas.mas_writer.new", skip_all)] pub async fn new( mut conn: LockedMasDatabase, mut writer_connections: Vec, + dry_run: bool, ) -> Result { // Given that we don't have any concurrent transactions here, // the READ COMMITTED isolation level is sufficient. @@ -504,7 +904,7 @@ impl MasWriter { Ok(Self { conn, - + dry_run, writer_pool: WriterConnectionPool::new(writer_connections), indices_to_restore, constraints_to_restore, @@ -589,7 +989,6 @@ impl MasWriter { // Now all the data has been migrated, finish off by restoring indices and // constraints! - query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;") .execute(self.conn.as_mut()) .await @@ -611,6 +1010,28 @@ impl MasWriter { .await .into_database("could not revert temporary tables")?; + // If we're in dry-run mode, truncate all the tables we've written to + if self.dry_run { + warn!("Migration ran in dry-run mode, deleting all imported data"); + let tables = MAS_TABLES_AFFECTED_BY_MIGRATION + .iter() + .map(|table| format!("\"{table}\"")) + .collect::>() + .join(", "); + + // Note that we do that with CASCADE, because we do that *after* + // restoring the FK constraints. + // + // The alternative would be to list all the tables we have FK to + // those tables, which would be a hassle, or to do that after + // restoring the constraints, which would mean we wouldn't validate + // that we've done valid FKs in dry-run mode. + query(&format!("TRUNCATE TABLE {tables} CASCADE;")) + .execute(self.conn.as_mut()) + .await + .into_database_with(|| "failed to truncate all tables")?; + } + query("COMMIT;") .execute(self.conn.as_mut()) .await @@ -624,492 +1045,26 @@ impl MasWriter { Ok(conn) } - - /// Write a batch of users to the database. - /// - /// # Errors - /// - /// Errors are returned in the following conditions: - /// - /// - If the database writer connection pool had an error. - #[allow(clippy::missing_panics_doc)] // not a real panic - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_users(&mut self, users: Vec) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool - .spawn_with_connection(move |conn| { - Box::pin(async move { - // `UNNEST` is a fast way to do bulk inserts, as it lets us send multiple rows - // in one statement without having to change the statement - // SQL thus altering the query plan. See . - // In the future we could consider using sqlx's support for `PgCopyIn` / the - // `COPY FROM STDIN` statement, which is allegedly the best - // for insert performance, but is less simple to encode. - let mut user_ids: Vec = Vec::with_capacity(users.len()); - let mut usernames: Vec = Vec::with_capacity(users.len()); - let mut created_ats: Vec> = Vec::with_capacity(users.len()); - let mut locked_ats: Vec>> = - Vec::with_capacity(users.len()); - let mut deactivated_ats: Vec>> = - Vec::with_capacity(users.len()); - let mut can_request_admins: Vec = Vec::with_capacity(users.len()); - let mut is_guests: Vec = Vec::with_capacity(users.len()); - for MasNewUser { - user_id, - username, - created_at, - locked_at, - deactivated_at, - can_request_admin, - is_guest, - } in users - { - user_ids.push(user_id.get()); - usernames.push(username); - created_ats.push(created_at); - locked_ats.push(locked_at); - deactivated_ats.push(deactivated_at); - can_request_admins.push(can_request_admin); - is_guests.push(is_guest); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__users ( - user_id, username, - created_at, locked_at, - deactivated_at, - can_request_admin, is_guest) - SELECT * FROM UNNEST( - $1::UUID[], $2::TEXT[], - $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[], - $5::TIMESTAMP WITH TIME ZONE[], - $6::BOOL[], $7::BOOL[]) - "#, - &user_ids[..], - &usernames[..], - &created_ats[..], - // We need to override the typing for arrays of optionals (sqlx limitation) - &locked_ats[..] as &[Option>], - &deactivated_ats[..] as &[Option>], - &can_request_admins[..], - &is_guests[..], - ) - .execute(&mut *conn) - .await - .into_database("writing users to MAS")?; - - Ok(()) - }) - }) - .boxed() - } - - /// Write a batch of user passwords to the database. - /// - /// # Errors - /// - /// Errors are returned in the following conditions: - /// - /// - If the database writer connection pool had an error. - #[allow(clippy::missing_panics_doc)] // not a real panic - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_passwords( - &mut self, - passwords: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool.spawn_with_connection(move |conn| Box::pin(async move { - let mut user_password_ids: Vec = Vec::with_capacity(passwords.len()); - let mut user_ids: Vec = Vec::with_capacity(passwords.len()); - let mut hashed_passwords: Vec = Vec::with_capacity(passwords.len()); - let mut created_ats: Vec> = Vec::with_capacity(passwords.len()); - let mut versions: Vec = Vec::with_capacity(passwords.len()); - for MasNewUserPassword { - user_password_id, - user_id, - hashed_password, - created_at, - } in passwords - { - user_password_ids.push(user_password_id); - user_ids.push(user_id.get()); - hashed_passwords.push(hashed_password); - created_ats.push(created_at); - versions.push(MIGRATED_PASSWORD_VERSION.into()); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__user_passwords - (user_password_id, user_id, hashed_password, created_at, version) - SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[]) - "#, - &user_password_ids[..], - &user_ids[..], - &hashed_passwords[..], - &created_ats[..], - &versions[..], - ).execute(&mut *conn).await.into_database("writing users to MAS")?; - - Ok(()) - })).boxed() - } - - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_email_threepids( - &mut self, - threepids: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool.spawn_with_connection(move |conn| { - Box::pin(async move { - let mut user_email_ids: Vec = Vec::with_capacity(threepids.len()); - let mut user_ids: Vec = Vec::with_capacity(threepids.len()); - let mut emails: Vec = Vec::with_capacity(threepids.len()); - let mut created_ats: Vec> = Vec::with_capacity(threepids.len()); - - for MasNewEmailThreepid { - user_email_id, - user_id, - email, - created_at, - } in threepids - { - user_email_ids.push(user_email_id); - user_ids.push(user_id.get()); - emails.push(email); - created_ats.push(created_at); - } - - // `confirmed_at` is going to get removed in a future MAS release, - // so just populate with `created_at` - sqlx::query!( - r#" - INSERT INTO syn2mas__user_emails - (user_email_id, user_id, email, created_at, confirmed_at) - SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[]) - "#, - &user_email_ids[..], - &user_ids[..], - &emails[..], - &created_ats[..], - ).execute(&mut *conn).await.into_database("writing emails to MAS")?; - - Ok(()) - }) - }).boxed() - } - - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_unsupported_threepids( - &mut self, - threepids: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool.spawn_with_connection(move |conn| { - Box::pin(async move { - let mut user_ids: Vec = Vec::with_capacity(threepids.len()); - let mut mediums: Vec = Vec::with_capacity(threepids.len()); - let mut addresses: Vec = Vec::with_capacity(threepids.len()); - let mut created_ats: Vec> = Vec::with_capacity(threepids.len()); - - for MasNewUnsupportedThreepid { - user_id, - medium, - address, - created_at, - } in threepids - { - user_ids.push(user_id.get()); - mediums.push(medium); - addresses.push(address); - created_ats.push(created_at); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__user_unsupported_third_party_ids - (user_id, medium, address, created_at) - SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[]) - "#, - &user_ids[..], - &mediums[..], - &addresses[..], - &created_ats[..], - ).execute(&mut *conn).await.into_database("writing unsupported threepids to MAS")?; - - Ok(()) - }) - }).boxed() - } - - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_upstream_oauth_links( - &mut self, - links: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool.spawn_with_connection(move |conn| { - Box::pin(async move { - let mut link_ids: Vec = Vec::with_capacity(links.len()); - let mut user_ids: Vec = Vec::with_capacity(links.len()); - let mut upstream_provider_ids: Vec = Vec::with_capacity(links.len()); - let mut subjects: Vec = Vec::with_capacity(links.len()); - let mut created_ats: Vec> = Vec::with_capacity(links.len()); - - for MasNewUpstreamOauthLink { - link_id, - user_id, - upstream_provider_id, - subject, - created_at, - } in links - { - link_ids.push(link_id); - user_ids.push(user_id.get()); - upstream_provider_ids.push(upstream_provider_id); - subjects.push(subject); - created_ats.push(created_at); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__upstream_oauth_links - (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at) - SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[]) - "#, - &link_ids[..], - &user_ids[..], - &upstream_provider_ids[..], - &subjects[..], - &created_ats[..], - ).execute(&mut *conn).await.into_database("writing unsupported threepids to MAS")?; - - Ok(()) - }) - }).boxed() - } - - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_compat_sessions( - &mut self, - sessions: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool - .spawn_with_connection(move |conn| { - Box::pin(async move { - let mut session_ids: Vec = Vec::with_capacity(sessions.len()); - let mut user_ids: Vec = Vec::with_capacity(sessions.len()); - let mut device_ids: Vec> = Vec::with_capacity(sessions.len()); - let mut human_names: Vec> = Vec::with_capacity(sessions.len()); - let mut created_ats: Vec> = Vec::with_capacity(sessions.len()); - let mut is_synapse_admins: Vec = Vec::with_capacity(sessions.len()); - let mut last_active_ats: Vec>> = - Vec::with_capacity(sessions.len()); - let mut last_active_ips: Vec> = - Vec::with_capacity(sessions.len()); - let mut user_agents: Vec> = Vec::with_capacity(sessions.len()); - - for MasNewCompatSession { - session_id, - user_id, - device_id, - human_name, - created_at, - is_synapse_admin, - last_active_at, - last_active_ip, - user_agent, - } in sessions - { - session_ids.push(session_id); - user_ids.push(user_id.get()); - device_ids.push(device_id); - human_names.push(human_name); - created_ats.push(created_at); - is_synapse_admins.push(is_synapse_admin); - last_active_ats.push(last_active_at); - last_active_ips.push(last_active_ip); - user_agents.push(user_agent); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__compat_sessions ( - compat_session_id, user_id, - device_id, human_name, - created_at, is_synapse_admin, - last_active_at, last_active_ip, - user_agent) - SELECT * FROM UNNEST( - $1::UUID[], $2::UUID[], - $3::TEXT[], $4::TEXT[], - $5::TIMESTAMP WITH TIME ZONE[], $6::BOOLEAN[], - $7::TIMESTAMP WITH TIME ZONE[], $8::INET[], - $9::TEXT[]) - "#, - &session_ids[..], - &user_ids[..], - &device_ids[..] as &[Option], - &human_names[..] as &[Option], - &created_ats[..], - &is_synapse_admins[..], - // We need to override the typing for arrays of optionals (sqlx limitation) - &last_active_ats[..] as &[Option>], - &last_active_ips[..] as &[Option], - &user_agents[..] as &[Option], - ) - .execute(&mut *conn) - .await - .into_database("writing compat sessions to MAS")?; - - Ok(()) - }) - }) - .boxed() - } - - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_compat_access_tokens( - &mut self, - tokens: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool - .spawn_with_connection(move |conn| { - Box::pin(async move { - let mut token_ids: Vec = Vec::with_capacity(tokens.len()); - let mut session_ids: Vec = Vec::with_capacity(tokens.len()); - let mut access_tokens: Vec = Vec::with_capacity(tokens.len()); - let mut created_ats: Vec> = Vec::with_capacity(tokens.len()); - let mut expires_ats: Vec>> = - Vec::with_capacity(tokens.len()); - - for MasNewCompatAccessToken { - token_id, - session_id, - access_token, - created_at, - expires_at, - } in tokens - { - token_ids.push(token_id); - session_ids.push(session_id); - access_tokens.push(access_token); - created_ats.push(created_at); - expires_ats.push(expires_at); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__compat_access_tokens ( - compat_access_token_id, - compat_session_id, - access_token, - created_at, - expires_at) - SELECT * FROM UNNEST( - $1::UUID[], - $2::UUID[], - $3::TEXT[], - $4::TIMESTAMP WITH TIME ZONE[], - $5::TIMESTAMP WITH TIME ZONE[]) - "#, - &token_ids[..], - &session_ids[..], - &access_tokens[..], - &created_ats[..], - // We need to override the typing for arrays of optionals (sqlx limitation) - &expires_ats[..] as &[Option>], - ) - .execute(&mut *conn) - .await - .into_database("writing compat access tokens to MAS")?; - - Ok(()) - }) - }) - .boxed() - } - - #[tracing::instrument(skip_all, level = Level::DEBUG)] - pub fn write_compat_refresh_tokens( - &mut self, - tokens: Vec, - ) -> BoxFuture<'_, Result<(), Error>> { - self.writer_pool - .spawn_with_connection(move |conn| { - Box::pin(async move { - let mut refresh_token_ids: Vec = Vec::with_capacity(tokens.len()); - let mut session_ids: Vec = Vec::with_capacity(tokens.len()); - let mut access_token_ids: Vec = Vec::with_capacity(tokens.len()); - let mut refresh_tokens: Vec = Vec::with_capacity(tokens.len()); - let mut created_ats: Vec> = Vec::with_capacity(tokens.len()); - - for MasNewCompatRefreshToken { - refresh_token_id, - session_id, - access_token_id, - refresh_token, - created_at, - } in tokens - { - refresh_token_ids.push(refresh_token_id); - session_ids.push(session_id); - access_token_ids.push(access_token_id); - refresh_tokens.push(refresh_token); - created_ats.push(created_at); - } - - sqlx::query!( - r#" - INSERT INTO syn2mas__compat_refresh_tokens ( - compat_refresh_token_id, - compat_session_id, - compat_access_token_id, - refresh_token, - created_at) - SELECT * FROM UNNEST( - $1::UUID[], - $2::UUID[], - $3::UUID[], - $4::TEXT[], - $5::TIMESTAMP WITH TIME ZONE[]) - "#, - &refresh_token_ids[..], - &session_ids[..], - &access_token_ids[..], - &refresh_tokens[..], - &created_ats[..], - ) - .execute(&mut *conn) - .await - .into_database("writing compat refresh tokens to MAS")?; - - Ok(()) - }) - }) - .boxed() - } } // How many entries to buffer at once, before writing a batch of rows to the // database. const WRITE_BUFFER_BATCH_SIZE: usize = 4096; -/// A function that can accept and flush buffers from a `MasWriteBuffer`. -/// Intended uses are the methods on `MasWriter` such as `write_users`. -type WriteBufferFlusher = - for<'a> fn(&'a mut MasWriter, Vec) -> BoxFuture<'a, Result<(), Error>>; - /// A buffer for writing rows to the MAS database. /// Generic over the type of rows. pub struct MasWriteBuffer { rows: Vec, - flusher: WriteBufferFlusher, finish_checker_handle: FinishCheckerHandle, } -impl MasWriteBuffer { - pub fn new(writer: &MasWriter, flusher: WriteBufferFlusher) -> Self { +impl MasWriteBuffer +where + T: WriteBatch, +{ + pub fn new(writer: &MasWriter) -> Self { MasWriteBuffer { rows: Vec::with_capacity(WRITE_BUFFER_BATCH_SIZE), - flusher, finish_checker_handle: writer.write_buffer_finish_checker.handle(), } } @@ -1126,7 +1081,11 @@ impl MasWriteBuffer { } let rows = std::mem::take(&mut self.rows); self.rows.reserve_exact(WRITE_BUFFER_BATCH_SIZE); - (self.flusher)(writer, rows).await?; + writer + .writer_pool + .spawn_with_connection(move |conn| T::write_batch(conn, rows).boxed()) + .boxed() + .await?; Ok(()) } @@ -1154,7 +1113,7 @@ mod test { mas_writer::{ MasNewCompatAccessToken, MasNewCompatRefreshToken, MasNewCompatSession, MasNewEmailThreepid, MasNewUnsupportedThreepid, MasNewUpstreamOauthLink, MasNewUser, - MasNewUserPassword, + MasNewUserPassword, MasWriteBuffer, }, }; @@ -1257,7 +1216,7 @@ mod test { .await .expect("failed to lock MAS database") .expect_left("MAS database is already locked"); - MasWriter::new(locked_main_conn, writer_conns) + MasWriter::new(locked_main_conn, writer_conns, false) .await .expect("failed to construct MasWriter") } @@ -1266,20 +1225,29 @@ mod test { #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_write_user(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; + let mut buffer = MasWriteBuffer::new(&writer); - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); + buffer + .finish(&mut writer) + .await + .expect("failed to finish MasWriter"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1295,28 +1263,47 @@ mod test { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: USER_ID, - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut password_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: USER_ID, + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_passwords(vec![MasNewUserPassword { - user_password_id: Uuid::from_u128(42u128), - user_id: USER_ID, - hashed_password: "$bcrypt$aaaaaaaaaaa".to_owned(), - created_at: DateTime::default(), - }]) + + password_buffer + .write( + &mut writer, + MasNewUserPassword { + user_password_id: Uuid::from_u128(42u128), + user_id: USER_ID, + hashed_password: "$bcrypt$aaaaaaaaaaa".to_owned(), + created_at: DateTime::default(), + }, + ) .await .expect("failed to write password"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish MasWriteBuffer"); + password_buffer + .finish(&mut writer) + .await + .expect("failed to finish MasWriteBuffer"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1330,29 +1317,47 @@ mod test { async fn test_write_user_with_email(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut email_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_email_threepids(vec![MasNewEmailThreepid { - user_email_id: Uuid::from_u128(2u128), - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - email: "alice@example.org".to_owned(), - created_at: DateTime::default(), - }]) + email_buffer + .write( + &mut writer, + MasNewEmailThreepid { + user_email_id: Uuid::from_u128(2u128), + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + email: "alice@example.org".to_owned(), + created_at: DateTime::default(), + }, + ) .await .expect("failed to write e-mail"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish user buffer"); + email_buffer + .finish(&mut writer) + .await + .expect("failed to finish email buffer"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1367,29 +1372,47 @@ mod test { async fn test_write_user_with_unsupported_threepid(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut threepid_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_unsupported_threepids(vec![MasNewUnsupportedThreepid { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - medium: "msisdn".to_owned(), - address: "441189998819991197253".to_owned(), - created_at: DateTime::default(), - }]) + threepid_buffer + .write( + &mut writer, + MasNewUnsupportedThreepid { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + medium: "msisdn".to_owned(), + address: "441189998819991197253".to_owned(), + created_at: DateTime::default(), + }, + ) .await .expect("failed to write phone number (unsupported threepid)"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish user buffer"); + threepid_buffer + .finish(&mut writer) + .await + .expect("failed to finish threepid buffer"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1405,30 +1428,48 @@ mod test { async fn test_write_user_with_upstream_provider_link(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut link_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_upstream_oauth_links(vec![MasNewUpstreamOauthLink { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - link_id: Uuid::from_u128(3u128), - upstream_provider_id: Uuid::from_u128(4u128), - subject: "12345.67890".to_owned(), - created_at: DateTime::default(), - }]) + link_buffer + .write( + &mut writer, + MasNewUpstreamOauthLink { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + link_id: Uuid::from_u128(3u128), + upstream_provider_id: Uuid::from_u128(4u128), + subject: "12345.67890".to_owned(), + created_at: DateTime::default(), + }, + ) .await .expect("failed to write link"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish user buffer"); + link_buffer + .finish(&mut writer) + .await + .expect("failed to finish link buffer"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1442,34 +1483,52 @@ mod test { async fn test_write_user_with_device(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut session_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_compat_sessions(vec![MasNewCompatSession { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - session_id: Uuid::from_u128(5u128), - created_at: DateTime::default(), - device_id: Some("ADEVICE".to_owned()), - human_name: Some("alice's pinephone".to_owned()), - is_synapse_admin: true, - last_active_at: Some(DateTime::default()), - last_active_ip: Some("203.0.113.1".parse().unwrap()), - user_agent: Some("Browser/5.0".to_owned()), - }]) + session_buffer + .write( + &mut writer, + MasNewCompatSession { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + session_id: Uuid::from_u128(5u128), + created_at: DateTime::default(), + device_id: Some("ADEVICE".to_owned()), + human_name: Some("alice's pinephone".to_owned()), + is_synapse_admin: true, + last_active_at: Some(DateTime::default()), + last_active_ip: Some("203.0.113.1".parse().unwrap()), + user_agent: Some("Browser/5.0".to_owned()), + }, + ) .await .expect("failed to write compat session"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish user buffer"); + session_buffer + .finish(&mut writer) + .await + .expect("failed to finish session buffer"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1483,45 +1542,71 @@ mod test { async fn test_write_user_with_access_token(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut session_buffer = MasWriteBuffer::new(&writer); + let mut token_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_compat_sessions(vec![MasNewCompatSession { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - session_id: Uuid::from_u128(5u128), - created_at: DateTime::default(), - device_id: Some("ADEVICE".to_owned()), - human_name: None, - is_synapse_admin: false, - last_active_at: None, - last_active_ip: None, - user_agent: None, - }]) + session_buffer + .write( + &mut writer, + MasNewCompatSession { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + session_id: Uuid::from_u128(5u128), + created_at: DateTime::default(), + device_id: Some("ADEVICE".to_owned()), + human_name: None, + is_synapse_admin: false, + last_active_at: None, + last_active_ip: None, + user_agent: None, + }, + ) .await .expect("failed to write compat session"); - writer - .write_compat_access_tokens(vec![MasNewCompatAccessToken { - token_id: Uuid::from_u128(6u128), - session_id: Uuid::from_u128(5u128), - access_token: "syt_zxcvzxcvzxcvzxcv_zxcv".to_owned(), - created_at: DateTime::default(), - expires_at: None, - }]) + token_buffer + .write( + &mut writer, + MasNewCompatAccessToken { + token_id: Uuid::from_u128(6u128), + session_id: Uuid::from_u128(5u128), + access_token: "syt_zxcvzxcvzxcvzxcv_zxcv".to_owned(), + created_at: DateTime::default(), + expires_at: None, + }, + ) .await .expect("failed to write access token"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish user buffer"); + session_buffer + .finish(&mut writer) + .await + .expect("failed to finish session buffer"); + token_buffer + .finish(&mut writer) + .await + .expect("failed to finish token buffer"); + let mut conn = writer .finish(&Progress::default()) .await @@ -1536,56 +1621,90 @@ mod test { async fn test_write_user_with_refresh_token(pool: PgPool) { let mut writer = make_mas_writer(&pool).await; - writer - .write_users(vec![MasNewUser { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - username: "alice".to_owned(), - created_at: DateTime::default(), - locked_at: None, - deactivated_at: None, - can_request_admin: false, - is_guest: false, - }]) + let mut user_buffer = MasWriteBuffer::new(&writer); + let mut session_buffer = MasWriteBuffer::new(&writer); + let mut token_buffer = MasWriteBuffer::new(&writer); + let mut refresh_token_buffer = MasWriteBuffer::new(&writer); + + user_buffer + .write( + &mut writer, + MasNewUser { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + deactivated_at: None, + can_request_admin: false, + is_guest: false, + }, + ) .await .expect("failed to write user"); - writer - .write_compat_sessions(vec![MasNewCompatSession { - user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), - session_id: Uuid::from_u128(5u128), - created_at: DateTime::default(), - device_id: Some("ADEVICE".to_owned()), - human_name: None, - is_synapse_admin: false, - last_active_at: None, - last_active_ip: None, - user_agent: None, - }]) + session_buffer + .write( + &mut writer, + MasNewCompatSession { + user_id: NonNilUuid::new(Uuid::from_u128(1u128)).unwrap(), + session_id: Uuid::from_u128(5u128), + created_at: DateTime::default(), + device_id: Some("ADEVICE".to_owned()), + human_name: None, + is_synapse_admin: false, + last_active_at: None, + last_active_ip: None, + user_agent: None, + }, + ) .await .expect("failed to write compat session"); - writer - .write_compat_access_tokens(vec![MasNewCompatAccessToken { - token_id: Uuid::from_u128(6u128), - session_id: Uuid::from_u128(5u128), - access_token: "syt_zxcvzxcvzxcvzxcv_zxcv".to_owned(), - created_at: DateTime::default(), - expires_at: None, - }]) + token_buffer + .write( + &mut writer, + MasNewCompatAccessToken { + token_id: Uuid::from_u128(6u128), + session_id: Uuid::from_u128(5u128), + access_token: "syt_zxcvzxcvzxcvzxcv_zxcv".to_owned(), + created_at: DateTime::default(), + expires_at: None, + }, + ) .await .expect("failed to write access token"); - writer - .write_compat_refresh_tokens(vec![MasNewCompatRefreshToken { - refresh_token_id: Uuid::from_u128(7u128), - session_id: Uuid::from_u128(5u128), - access_token_id: Uuid::from_u128(6u128), - refresh_token: "syr_zxcvzxcvzxcvzxcv_zxcv".to_owned(), - created_at: DateTime::default(), - }]) + refresh_token_buffer + .write( + &mut writer, + MasNewCompatRefreshToken { + refresh_token_id: Uuid::from_u128(7u128), + session_id: Uuid::from_u128(5u128), + access_token_id: Uuid::from_u128(6u128), + refresh_token: "syr_zxcvzxcvzxcvzxcv_zxcv".to_owned(), + created_at: DateTime::default(), + }, + ) .await .expect("failed to write refresh token"); + user_buffer + .finish(&mut writer) + .await + .expect("failed to finish user buffer"); + session_buffer + .finish(&mut writer) + .await + .expect("failed to finish session buffer"); + token_buffer + .finish(&mut writer) + .await + .expect("failed to finish token buffer"); + refresh_token_buffer + .finish(&mut writer) + .await + .expect("failed to finish refresh token buffer"); + let mut conn = writer .finish(&Progress::default()) .await 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 1fbf6a100..a368aa9a5 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 @@ -20,6 +20,7 @@ upstream_oauth_providers: discovery_mode: oidc encrypted_client_secret: ~ fetch_userinfo: "false" + forward_login_hint: "false" human_name: ~ id_token_signed_response_alg: RS256 issuer: ~ diff --git a/crates/syn2mas/src/migration.rs b/crates/syn2mas/src/migration.rs index efefc25d7..2a906d933 100644 --- a/crates/syn2mas/src/migration.rs +++ b/crates/syn2mas/src/migration.rs @@ -11,13 +11,12 @@ //! This module does not implement any of the safety checks that should be run //! *before* the migration. -use std::{pin::pin, time::Instant}; +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 opentelemetry::{KeyValue, metrics::Counter}; use rand::{RngCore, SeedableRng}; use thiserror::Error; use thiserror_ext::ContextInto; @@ -33,16 +32,11 @@ use crate::{ MasNewEmailThreepid, MasNewUnsupportedThreepid, MasNewUpstreamOauthLink, MasNewUser, MasNewUserPassword, MasWriteBuffer, MasWriter, }, - progress::Progress, + progress::{EntityType, Progress}, synapse_reader::{ self, ExtractLocalpartError, FullUserId, SynapseAccessToken, SynapseDevice, SynapseExternalId, SynapseRefreshableTokenPair, SynapseThreepid, SynapseUser, }, - telemetry::{ - K_ENTITY, METER, V_ENTITY_DEVICES, V_ENTITY_EXTERNAL_IDS, - V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS, V_ENTITY_REFRESHABLE_TOKEN_PAIRS, - V_ENTITY_THREEPIDS, V_ENTITY_USERS, - }, }; #[derive(Debug, Error, ContextInto)] @@ -146,7 +140,7 @@ struct MigrationState { /// /// - An underlying database access error, either to MAS or to Synapse. /// - Invalid data in the Synapse database. -#[allow(clippy::implicit_hasher, clippy::too_many_lines)] +#[expect(clippy::implicit_hasher)] pub async fn migrate( mut synapse: SynapseReader<'_>, mas: MasWriter, @@ -158,49 +152,6 @@ pub async fn migrate( ) -> Result<(), Error> { let counts = synapse.count_rows().await.into_synapse("counting users")?; - let approx_total_counter = METER - .u64_counter("syn2mas.entity.approx_total") - .with_description("Approximate number of entities of this type to be migrated") - .build(); - let migrated_otel_counter = METER - .u64_counter("syn2mas.entity.migrated") - .with_description("Number of entities of this type that have been migrated so far") - .build(); - let skipped_otel_counter = METER - .u64_counter("syn2mas.entity.skipped") - .with_description("Number of entities of this type that have been skipped so far") - .build(); - - approx_total_counter.add( - counts.users as u64, - &[KeyValue::new(K_ENTITY, V_ENTITY_USERS)], - ); - approx_total_counter.add( - counts.devices as u64, - &[KeyValue::new(K_ENTITY, V_ENTITY_DEVICES)], - ); - approx_total_counter.add( - counts.threepids as u64, - &[KeyValue::new(K_ENTITY, V_ENTITY_THREEPIDS)], - ); - approx_total_counter.add( - counts.external_ids as u64, - &[KeyValue::new(K_ENTITY, V_ENTITY_EXTERNAL_IDS)], - ); - // assume 1 refreshable access token per refresh token. - let approx_nonrefreshable_access_tokens = counts.access_tokens - counts.refresh_tokens; - approx_total_counter.add( - approx_nonrefreshable_access_tokens as u64, - &[KeyValue::new( - K_ENTITY, - V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS, - )], - ); - approx_total_counter.add( - counts.refresh_tokens as u64, - &[KeyValue::new(K_ENTITY, V_ENTITY_REFRESHABLE_TOKEN_PAIRS)], - ); - let state = MigrationState { server_name, // We oversize the hashmaps, as the estimates are innaccurate, and we would like to avoid @@ -213,83 +164,32 @@ pub async fn migrate( provider_id_mapping, }; - let progress_counter = progress.migrating_data(V_ENTITY_USERS, counts.users); - let (mas, state) = migrate_users( - &mut synapse, - mas, - state, - rng, - progress_counter, - migrated_otel_counter.clone(), - skipped_otel_counter.clone(), - ) - .await?; + let progress_counter = progress.migrating_data(EntityType::Users, counts.users); + let (mas, state) = migrate_users(&mut synapse, mas, state, rng, progress_counter).await?; - let progress_counter = progress.migrating_data(V_ENTITY_THREEPIDS, counts.threepids); - let (mas, state) = migrate_threepids( - &mut synapse, - mas, - rng, - state, - progress_counter, - migrated_otel_counter.clone(), - skipped_otel_counter.clone(), - ) - .await?; + let progress_counter = progress.migrating_data(EntityType::ThreePids, counts.threepids); + let (mas, state) = migrate_threepids(&mut synapse, mas, rng, state, progress_counter).await?; - let progress_counter = progress.migrating_data(V_ENTITY_EXTERNAL_IDS, counts.external_ids); - let (mas, state) = migrate_external_ids( - &mut synapse, - mas, - rng, - state, - progress_counter, - migrated_otel_counter.clone(), - skipped_otel_counter.clone(), - ) - .await?; + let progress_counter = progress.migrating_data(EntityType::ExternalIds, counts.external_ids); + let (mas, state) = + migrate_external_ids(&mut synapse, mas, rng, state, progress_counter).await?; let progress_counter = progress.migrating_data( - V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS, + EntityType::NonRefreshableAccessTokens, counts.access_tokens - counts.refresh_tokens, ); - let (mas, state) = migrate_unrefreshable_access_tokens( - &mut synapse, - mas, - clock, - rng, - state, - progress_counter, - migrated_otel_counter.clone(), - skipped_otel_counter.clone(), - ) - .await?; + let (mas, state) = + migrate_unrefreshable_access_tokens(&mut synapse, mas, clock, rng, state, progress_counter) + .await?; let progress_counter = - progress.migrating_data(V_ENTITY_REFRESHABLE_TOKEN_PAIRS, counts.refresh_tokens); - let (mas, state) = migrate_refreshable_token_pairs( - &mut synapse, - mas, - clock, - rng, - state, - progress_counter, - migrated_otel_counter.clone(), - skipped_otel_counter.clone(), - ) - .await?; + progress.migrating_data(EntityType::RefreshableTokens, counts.refresh_tokens); + let (mas, state) = + migrate_refreshable_token_pairs(&mut synapse, mas, clock, rng, state, progress_counter) + .await?; - let progress_counter = progress.migrating_data("devices", counts.devices); - let (mas, _state) = migrate_devices( - &mut synapse, - mas, - rng, - state, - progress_counter, - migrated_otel_counter.clone(), - skipped_otel_counter.clone(), - ) - .await?; + let progress_counter = progress.migrating_data(EntityType::Devices, counts.devices); + let (mas, _state) = migrate_devices(&mut synapse, mas, rng, state, progress_counter).await?; synapse .finish() @@ -310,21 +210,19 @@ async fn migrate_users( mut state: MigrationState, rng: &mut impl RngCore, progress_counter: ProgressCounter, - migrated_otel_counter: Counter, - skipped_otel_counter: Counter, ) -> Result<(MasWriter, MigrationState), Error> { let start = Instant::now(); - let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_USERS)]; + let progress_counter_ = progress_counter.clone(); - let (tx, mut rx) = tokio::sync::mpsc::channel::(10 * 1024 * 1024); + let (tx, mut rx) = tokio::sync::mpsc::channel::(100 * 1024); // create a new RNG seeded from the passed RNG so that we can move it into the // spawned task let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng"); let task = tokio::spawn( async move { - let mut user_buffer = MasWriteBuffer::new(&mas, MasWriter::write_users); - let mut password_buffer = MasWriteBuffer::new(&mas, MasWriter::write_passwords); + let mut user_buffer = MasWriteBuffer::new(&mas); + let mut password_buffer = MasWriteBuffer::new(&mas); while let Some(user) = rx.recv().await { // Handling an edge case: some AS users may have invalid localparts containing @@ -356,7 +254,6 @@ async fn migrate_users( if user.appservice_id.is_some() { flags |= UserFlags::IS_APPSERVICE; - skipped_otel_counter.add(1, &otel_kv); progress_counter.increment_skipped(); // Special case for appservice users: we don't insert them into the database @@ -391,7 +288,6 @@ async fn migrate_users( .into_mas("writing password")?; } - migrated_otel_counter.add(1, &otel_kv); progress_counter.increment_migrated(); } @@ -423,7 +319,9 @@ async fn migrate_users( res?; info!( - "users migrated in {:.1}s", + "{} users migrated ({} skipped) in {:.1}s", + progress_counter_.migrated(), + progress_counter_.skipped(), Instant::now().duration_since(start).as_secs_f64() ); @@ -437,98 +335,116 @@ async fn migrate_threepids( rng: &mut impl RngCore, state: MigrationState, progress_counter: ProgressCounter, - migrated_otel_counter: Counter, - skipped_otel_counter: Counter, ) -> Result<(MasWriter, MigrationState), Error> { let start = Instant::now(); - let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_THREEPIDS)]; + let progress_counter_ = progress_counter.clone(); - let mut email_buffer = MasWriteBuffer::new(&mas, MasWriter::write_email_threepids); - let mut unsupported_buffer = MasWriteBuffer::new(&mas, MasWriter::write_unsupported_threepids); - let mut users_stream = pin!(synapse.read_threepids()); + let (tx, mut rx) = tokio::sync::mpsc::channel::(100 * 1024); - while let Some(threepid_res) = users_stream.next().await { - let SynapseThreepid { - user_id: synapse_user_id, - medium, - address, - added_at, - } = threepid_res.into_synapse("reading threepid")?; - let created_at: DateTime = added_at.into(); + // create a new RNG seeded from the passed RNG so that we can move it into the + // spawned task + let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng"); + let task = tokio::spawn( + async move { + let mut email_buffer = MasWriteBuffer::new(&mas); + let mut unsupported_buffer = MasWriteBuffer::new(&mas); - let username = synapse_user_id - .extract_localpart(&state.server_name) - .into_extract_localpart(synapse_user_id.clone())? - .to_owned(); - let Some(user_infos) = state.users.get(username.as_str()).copied() else { - return Err(Error::MissingUserFromDependentTable { - table: "user_threepids".to_owned(), - user: synapse_user_id, - }); - }; + while let Some(threepid) = rx.recv().await { + let SynapseThreepid { + user_id: synapse_user_id, + medium, + address, + added_at, + } = threepid; + let created_at: DateTime = added_at.into(); - let Some(mas_user_id) = user_infos.mas_user_id else { - progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); - continue; - }; + let username = synapse_user_id + .extract_localpart(&state.server_name) + .into_extract_localpart(synapse_user_id.clone())? + .to_owned(); + let Some(user_infos) = state.users.get(username.as_str()).copied() else { + return Err(Error::MissingUserFromDependentTable { + table: "user_threepids".to_owned(), + user: synapse_user_id, + }); + }; + + let Some(mas_user_id) = user_infos.mas_user_id else { + progress_counter.increment_skipped(); + continue; + }; + + if medium == "email" { + email_buffer + .write( + &mut mas, + MasNewEmailThreepid { + user_id: mas_user_id, + user_email_id: Uuid::from(Ulid::from_datetime_with_source( + created_at.into(), + &mut rng, + )), + email: address, + created_at, + }, + ) + .await + .into_mas("writing email")?; + } else { + unsupported_buffer + .write( + &mut mas, + MasNewUnsupportedThreepid { + user_id: mas_user_id, + medium, + address, + created_at, + }, + ) + .await + .into_mas("writing unsupported threepid")?; + } + + progress_counter.increment_migrated(); + } - if medium == "email" { email_buffer - .write( - &mut mas, - MasNewEmailThreepid { - user_id: mas_user_id, - user_email_id: Uuid::from(Ulid::from_datetime_with_source( - created_at.into(), - rng, - )), - email: address, - created_at, - }, - ) + .finish(&mut mas) .await - .into_mas("writing email")?; - } else { + .into_mas("writing email threepids")?; unsupported_buffer - .write( - &mut mas, - MasNewUnsupportedThreepid { - user_id: mas_user_id, - medium, - address, - created_at, - }, - ) + .finish(&mut mas) .await - .into_mas("writing unsupported threepid")?; + .into_mas("writing unsupported threepids")?; + + Ok((mas, state)) } + .instrument(tracing::info_span!("ingest_task")), + ); - migrated_otel_counter.add(1, &otel_kv); - progress_counter.increment_migrated(); - } + // In case this has an error, we still want to join the task, so we look at the + // error later + let res = synapse + .read_threepids() + .map_err(|e| e.into_synapse("reading threepids")) + .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed)) + .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error)) + .await; - email_buffer - .finish(&mut mas) - .await - .into_mas("writing email threepids")?; - unsupported_buffer - .finish(&mut mas) - .await - .into_mas("writing unsupported threepids")?; + let (mas, state) = task.await.into_join("threepid write task")??; + + res?; info!( - "third-party IDs migrated in {:.1}s", + "{} third-party IDs migrated ({} skipped) in {:.1}s", + progress_counter_.migrated(), + progress_counter_.skipped(), Instant::now().duration_since(start).as_secs_f64() ); Ok((mas, state)) } -/// # Parameters -/// -/// - `provider_id_mapping`: mapping from Synapse `auth_provider` ID to UUID of -/// the upstream provider in MAS. #[tracing::instrument(skip_all, level = Level::INFO)] async fn migrate_external_ids( synapse: &mut SynapseReader<'_>, @@ -536,76 +452,100 @@ async fn migrate_external_ids( rng: &mut impl RngCore, state: MigrationState, progress_counter: ProgressCounter, - migrated_otel_counter: Counter, - skipped_otel_counter: Counter, ) -> Result<(MasWriter, MigrationState), Error> { let start = Instant::now(); - let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_EXTERNAL_IDS)]; + let progress_counter_ = progress_counter.clone(); - let mut write_buffer = MasWriteBuffer::new(&mas, MasWriter::write_upstream_oauth_links); - let mut extids_stream = pin!(synapse.read_user_external_ids()); + let (tx, mut rx) = tokio::sync::mpsc::channel::(100 * 1024); - while let Some(extid_res) = extids_stream.next().await { - let SynapseExternalId { - user_id: synapse_user_id, - auth_provider, - external_id: subject, - } = extid_res.into_synapse("reading external ID")?; - let username = synapse_user_id - .extract_localpart(&state.server_name) - .into_extract_localpart(synapse_user_id.clone())? - .to_owned(); - let Some(user_infos) = state.users.get(username.as_str()).copied() else { - return Err(Error::MissingUserFromDependentTable { - table: "user_external_ids".to_owned(), - user: synapse_user_id, - }); - }; + // create a new RNG seeded from the passed RNG so that we can move it into the + // spawned task + let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng"); + let task = tokio::spawn( + async move { + let mut write_buffer = MasWriteBuffer::new(&mas); - let Some(mas_user_id) = user_infos.mas_user_id else { - progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); - continue; - }; + while let Some(extid) = rx.recv().await { + let SynapseExternalId { + user_id: synapse_user_id, + auth_provider, + external_id: subject, + } = extid; + let username = synapse_user_id + .extract_localpart(&state.server_name) + .into_extract_localpart(synapse_user_id.clone())? + .to_owned(); + let Some(user_infos) = state.users.get(username.as_str()).copied() else { + return Err(Error::MissingUserFromDependentTable { + table: "user_external_ids".to_owned(), + user: synapse_user_id, + }); + }; - let Some(&upstream_provider_id) = state.provider_id_mapping.get(&auth_provider) else { - return Err(Error::MissingAuthProviderMapping { - synapse_id: auth_provider, - user: synapse_user_id, - }); - }; + let Some(mas_user_id) = user_infos.mas_user_id else { + progress_counter.increment_skipped(); + continue; + }; - // To save having to store user creation times, extract it from the ULID - // This gives millisecond precision — good enough. - let user_created_ts = Ulid::from(mas_user_id.get()).datetime(); + let Some(&upstream_provider_id) = state.provider_id_mapping.get(&auth_provider) + else { + return Err(Error::MissingAuthProviderMapping { + synapse_id: auth_provider, + user: synapse_user_id, + }); + }; - let link_id: Uuid = Ulid::from_datetime_with_source(user_created_ts, rng).into(); + // To save having to store user creation times, extract it from the ULID + // This gives millisecond precision — good enough. + let user_created_ts = Ulid::from(mas_user_id.get()).datetime(); - write_buffer - .write( - &mut mas, - MasNewUpstreamOauthLink { - link_id, - user_id: mas_user_id, - upstream_provider_id, - subject, - created_at: user_created_ts.into(), - }, - ) - .await - .into_mas("failed to write upstream link")?; + let link_id: Uuid = + Ulid::from_datetime_with_source(user_created_ts, &mut rng).into(); - migrated_otel_counter.add(1, &otel_kv); - progress_counter.increment_migrated(); - } + write_buffer + .write( + &mut mas, + MasNewUpstreamOauthLink { + link_id, + user_id: mas_user_id, + upstream_provider_id, + subject, + created_at: user_created_ts.into(), + }, + ) + .await + .into_mas("failed to write upstream link")?; - write_buffer - .finish(&mut mas) - .await - .into_mas("writing upstream links")?; + progress_counter.increment_migrated(); + } + + write_buffer + .finish(&mut mas) + .await + .into_mas("writing upstream links")?; + + Ok((mas, state)) + } + .instrument(tracing::info_span!("ingest_task")), + ); + + // In case this has an error, we still want to join the task, so we look at the + // error later + let res = synapse + .read_user_external_ids() + .map_err(|e| e.into_synapse("reading external ID")) + .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed)) + .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error)) + .await; + + let (mas, state) = task.await.into_join("external IDs write task")??; + + res?; info!( - "upstream links (external IDs) migrated in {:.1}s", + "{} upstream links (external IDs) migrated ({} skipped) in {:.1}s", + progress_counter_.migrated(), + progress_counter_.skipped(), Instant::now().duration_since(start).as_secs_f64() ); @@ -627,20 +567,18 @@ async fn migrate_devices( rng: &mut impl RngCore, mut state: MigrationState, progress_counter: ProgressCounter, - migrated_otel_counter: Counter, - skipped_otel_counter: Counter, ) -> Result<(MasWriter, MigrationState), Error> { let start = Instant::now(); - let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_DEVICES)]; + let progress_counter_ = progress_counter.clone(); - let (tx, mut rx) = tokio::sync::mpsc::channel(10 * 1024 * 1024); + let (tx, mut rx) = tokio::sync::mpsc::channel(100 * 1024); // create a new RNG seeded from the passed RNG so that we can move it into the // spawned task let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng"); let task = tokio::spawn( async move { - let mut write_buffer = MasWriteBuffer::new(&mas, MasWriter::write_compat_sessions); + let mut write_buffer = MasWriteBuffer::new(&mas); while let Some(device) = rx.recv().await { let SynapseDevice { @@ -664,7 +602,6 @@ async fn migrate_devices( let Some(mas_user_id) = user_infos.mas_user_id else { progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); continue; }; @@ -721,7 +658,6 @@ async fn migrate_devices( .await .into_mas("writing compat sessions")?; - migrated_otel_counter.add(1, &otel_kv); progress_counter.increment_migrated(); } @@ -749,7 +685,9 @@ async fn migrate_devices( res?; info!( - "devices migrated in {:.1}s", + "{} devices migrated ({} skipped) in {:.1}s", + progress_counter_.migrated(), + progress_counter_.skipped(), Instant::now().duration_since(start).as_secs_f64() ); @@ -759,7 +697,6 @@ async fn migrate_devices( /// Migrates unrefreshable access tokens (those without an associated refresh /// token). Some of these may be deviceless. #[tracing::instrument(skip_all, level = Level::INFO)] -#[allow(clippy::too_many_arguments)] async fn migrate_unrefreshable_access_tokens( synapse: &mut SynapseReader<'_>, mut mas: MasWriter, @@ -767,16 +704,11 @@ async fn migrate_unrefreshable_access_tokens( rng: &mut impl RngCore, mut state: MigrationState, progress_counter: ProgressCounter, - migrated_otel_counter: Counter, - skipped_otel_counter: Counter, ) -> Result<(MasWriter, MigrationState), Error> { let start = Instant::now(); - let otel_kv = [KeyValue::new( - K_ENTITY, - V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS, - )]; + let progress_counter_ = progress_counter.clone(); - let (tx, mut rx) = tokio::sync::mpsc::channel(10 * 1024 * 1024); + let (tx, mut rx) = tokio::sync::mpsc::channel(100 * 1024); let now = clock.now(); // create a new RNG seeded from the passed RNG so that we can move it into the @@ -784,9 +716,8 @@ async fn migrate_unrefreshable_access_tokens( let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng"); let task = tokio::spawn( async move { - let mut write_buffer = MasWriteBuffer::new(&mas, MasWriter::write_compat_access_tokens); - let mut deviceless_session_write_buffer = - MasWriteBuffer::new(&mas, MasWriter::write_compat_sessions); + let mut write_buffer = MasWriteBuffer::new(&mas); + let mut deviceless_session_write_buffer = MasWriteBuffer::new(&mas); while let Some(token) = rx.recv().await { let SynapseAccessToken { @@ -809,7 +740,6 @@ async fn migrate_unrefreshable_access_tokens( let Some(mas_user_id) = user_infos.mas_user_id else { progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); continue; }; @@ -818,7 +748,6 @@ async fn migrate_unrefreshable_access_tokens( || user_infos.flags.is_appservice() { progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); continue; } @@ -879,7 +808,6 @@ async fn migrate_unrefreshable_access_tokens( .await .into_mas("writing compat access tokens")?; - migrated_otel_counter.add(1, &otel_kv); progress_counter.increment_migrated(); } write_buffer @@ -910,7 +838,9 @@ async fn migrate_unrefreshable_access_tokens( res?; info!( - "non-refreshable access tokens migrated in {:.1}s", + "{} non-refreshable access tokens migrated ({} skipped) in {:.1}s", + progress_counter_.migrated(), + progress_counter_.skipped(), Instant::now().duration_since(start).as_secs_f64() ); @@ -920,7 +850,6 @@ async fn migrate_unrefreshable_access_tokens( /// Migrates (access token, refresh token) pairs. /// Does not migrate non-refreshable access tokens. #[tracing::instrument(skip_all, level = Level::INFO)] -#[allow(clippy::too_many_arguments)] async fn migrate_refreshable_token_pairs( synapse: &mut SynapseReader<'_>, mut mas: MasWriter, @@ -928,111 +857,134 @@ async fn migrate_refreshable_token_pairs( rng: &mut impl RngCore, mut state: MigrationState, progress_counter: ProgressCounter, - migrated_otel_counter: Counter, - skipped_otel_counter: Counter, ) -> Result<(MasWriter, MigrationState), Error> { let start = Instant::now(); - let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_REFRESHABLE_TOKEN_PAIRS)]; + let progress_counter_ = progress_counter.clone(); - let mut token_stream = pin!(synapse.read_refreshable_token_pairs()); - let mut access_token_write_buffer = - MasWriteBuffer::new(&mas, MasWriter::write_compat_access_tokens); - let mut refresh_token_write_buffer = - MasWriteBuffer::new(&mas, MasWriter::write_compat_refresh_tokens); + let (tx, mut rx) = tokio::sync::mpsc::channel::(100 * 1024); - while let Some(token_res) = token_stream.next().await { - let SynapseRefreshableTokenPair { - user_id: synapse_user_id, - device_id, - access_token, - refresh_token, - valid_until_ms, - last_validated, - } = token_res.into_synapse("reading Synapse refresh token")?; + // create a new RNG seeded from the passed RNG so that we can move it into the + // spawned task + let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng"); + let now = clock.now(); + let task = tokio::spawn( + async move { + let mut access_token_write_buffer = MasWriteBuffer::new(&mas); + let mut refresh_token_write_buffer = MasWriteBuffer::new(&mas); - let username = synapse_user_id - .extract_localpart(&state.server_name) - .into_extract_localpart(synapse_user_id.clone())? - .to_owned(); - let Some(user_infos) = state.users.get(username.as_str()).copied() else { - return Err(Error::MissingUserFromDependentTable { - table: "refresh_tokens".to_owned(), - user: synapse_user_id, - }); - }; - - let Some(mas_user_id) = user_infos.mas_user_id else { - progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); - continue; - }; - - if user_infos.flags.is_deactivated() - || user_infos.flags.is_guest() - || user_infos.flags.is_appservice() - { - progress_counter.increment_skipped(); - skipped_otel_counter.add(1, &otel_kv); - continue; - } - - // It's not always accurate, but last_validated is *often* the creation time of - // the device If we don't have one, then use the current time as a - // fallback. - let created_at = last_validated.map_or_else(|| clock.now(), DateTime::from); - - // Use the existing device_id if this is the second token for a device - let session_id = *state - .devices_to_compat_sessions - .entry((mas_user_id, CompactString::new(&device_id))) - .or_insert_with(|| Uuid::from(Ulid::from_datetime_with_source(created_at.into(), rng))); - - let access_token_id = Uuid::from(Ulid::from_datetime_with_source(created_at.into(), rng)); - let refresh_token_id = Uuid::from(Ulid::from_datetime_with_source(created_at.into(), rng)); - - access_token_write_buffer - .write( - &mut mas, - MasNewCompatAccessToken { - token_id: access_token_id, - session_id, + while let Some(token) = rx.recv().await { + let SynapseRefreshableTokenPair { + user_id: synapse_user_id, + device_id, access_token, - created_at, - expires_at: valid_until_ms.map(DateTime::from), - }, - ) - .await - .into_mas("writing compat access tokens")?; - refresh_token_write_buffer - .write( - &mut mas, - MasNewCompatRefreshToken { - refresh_token_id, - session_id, - access_token_id, refresh_token, - created_at, - }, - ) - .await - .into_mas("writing compat refresh tokens")?; + valid_until_ms, + last_validated, + } = token; - migrated_otel_counter.add(1, &otel_kv); - progress_counter.increment_migrated(); - } + let username = synapse_user_id + .extract_localpart(&state.server_name) + .into_extract_localpart(synapse_user_id.clone())? + .to_owned(); + let Some(user_infos) = state.users.get(username.as_str()).copied() else { + return Err(Error::MissingUserFromDependentTable { + table: "refresh_tokens".to_owned(), + user: synapse_user_id, + }); + }; - access_token_write_buffer - .finish(&mut mas) - .await - .into_mas("writing compat access tokens")?; + let Some(mas_user_id) = user_infos.mas_user_id else { + progress_counter.increment_skipped(); + continue; + }; - refresh_token_write_buffer - .finish(&mut mas) - .await - .into_mas("writing compat refresh tokens")?; + if user_infos.flags.is_deactivated() + || user_infos.flags.is_guest() + || user_infos.flags.is_appservice() + { + progress_counter.increment_skipped(); + continue; + } + + // It's not always accurate, but last_validated is *often* the creation time of + // the device If we don't have one, then use the current time as a + // fallback. + let created_at = last_validated.map_or_else(|| now, DateTime::from); + + // Use the existing device_id if this is the second token for a device + let session_id = *state + .devices_to_compat_sessions + .entry((mas_user_id, CompactString::new(&device_id))) + .or_insert_with(|| { + Uuid::from(Ulid::from_datetime_with_source(created_at.into(), &mut rng)) + }); + + let access_token_id = + Uuid::from(Ulid::from_datetime_with_source(created_at.into(), &mut rng)); + let refresh_token_id = + Uuid::from(Ulid::from_datetime_with_source(created_at.into(), &mut rng)); + + access_token_write_buffer + .write( + &mut mas, + MasNewCompatAccessToken { + token_id: access_token_id, + session_id, + access_token, + created_at, + expires_at: valid_until_ms.map(DateTime::from), + }, + ) + .await + .into_mas("writing compat access tokens")?; + refresh_token_write_buffer + .write( + &mut mas, + MasNewCompatRefreshToken { + refresh_token_id, + session_id, + access_token_id, + refresh_token, + created_at, + }, + ) + .await + .into_mas("writing compat refresh tokens")?; + + progress_counter.increment_migrated(); + } + + access_token_write_buffer + .finish(&mut mas) + .await + .into_mas("writing compat access tokens")?; + + refresh_token_write_buffer + .finish(&mut mas) + .await + .into_mas("writing compat refresh tokens")?; + Ok((mas, state)) + } + .instrument(tracing::info_span!("ingest_task")), + ); + + // In case this has an error, we still want to join the task, so we look at the + // error later + let res = synapse + .read_refreshable_token_pairs() + .map_err(|e| e.into_synapse("reading refresh token pairs")) + .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed)) + .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error)) + .await; + + let (mas, state) = task.await.into_join("refresh token write task")??; + + res?; info!( - "refreshable token pairs migrated in {:.1}s", + "{} refreshable token pairs migrated ({} skipped) in {:.1}s", + progress_counter_.migrated(), + progress_counter_.skipped(), Instant::now().duration_since(start).as_secs_f64() ); diff --git a/crates/syn2mas/src/progress.rs b/crates/syn2mas/src/progress.rs index e5f61d292..3c67825ce 100644 --- a/crates/syn2mas/src/progress.rs +++ b/crates/syn2mas/src/progress.rs @@ -1,6 +1,89 @@ -use std::sync::{Arc, atomic::AtomicU32}; +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::sync::{Arc, LazyLock, atomic::AtomicU32}; use arc_swap::ArcSwap; +use opentelemetry::{ + KeyValue, + metrics::{Counter, Gauge}, +}; + +use crate::telemetry::METER; + +/// A gauge that tracks the approximate number of entities of a given type +/// that will be migrated. +pub static APPROX_TOTAL_GAUGE: LazyLock> = LazyLock::new(|| { + METER + .u64_gauge("syn2mas.entity.approx_total") + .with_description("Approximate number of entities of this type to be migrated") + .build() +}); + +/// A counter that tracks the number of entities of a given type that have +/// been migrated so far. +pub static MIGRATED_COUNTER: LazyLock> = LazyLock::new(|| { + METER + .u64_counter("syn2mas.entity.migrated") + .with_description("Number of entities of this type that have been migrated so far") + .build() +}); + +/// A counter that tracks the number of entities of a given type that have +/// been skipped so far. +pub static SKIPPED_COUNTER: LazyLock> = LazyLock::new(|| { + METER + .u64_counter("syn2mas.entity.skipped") + .with_description("Number of entities of this type that have been skipped so far") + .build() +}); + +/// Enum representing the different types of entities that syn2mas can migrate. +#[derive(Debug, Clone, Copy)] +pub enum EntityType { + /// Represents users + Users, + + /// Represents devices + Devices, + + /// Represents third-party IDs + ThreePids, + + /// Represents external IDs + ExternalIds, + + /// Represents non-refreshable access tokens + NonRefreshableAccessTokens, + + /// Represents refreshable access tokens + RefreshableTokens, +} + +impl std::fmt::Display for EntityType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl EntityType { + pub const fn name(self) -> &'static str { + match self { + Self::Users => "users", + Self::Devices => "devices", + Self::ThreePids => "threepids", + Self::ExternalIds => "external_ids", + Self::NonRefreshableAccessTokens => "nonrefreshable_access_tokens", + Self::RefreshableTokens => "refreshable_tokens", + } + } + + pub fn as_kv(self) -> KeyValue { + KeyValue::new("entity", self.name()) + } +} /// Tracker for the progress of the migration /// @@ -11,25 +94,37 @@ pub struct Progress { current_stage: Arc>, } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct ProgressCounter { inner: Arc, } -#[derive(Default)] struct ProgressCounterInner { + kv: [KeyValue; 1], migrated: AtomicU32, skipped: AtomicU32, } impl ProgressCounter { + fn new(entity: EntityType) -> Self { + Self { + inner: Arc::new(ProgressCounterInner { + kv: [entity.as_kv()], + migrated: AtomicU32::new(0), + skipped: AtomicU32::new(0), + }), + } + } + pub fn increment_migrated(&self) { + MIGRATED_COUNTER.add(1, &self.inner.kv); self.inner .migrated .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } pub fn increment_skipped(&self) { + SKIPPED_COUNTER.add(1, &self.inner.kv); self.inner .skipped .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -52,8 +147,9 @@ impl ProgressCounter { impl Progress { #[must_use] - pub fn migrating_data(&self, entity: &'static str, approx_count: usize) -> ProgressCounter { - let counter = ProgressCounter::default(); + pub fn migrating_data(&self, entity: EntityType, approx_count: usize) -> ProgressCounter { + let counter = ProgressCounter::new(entity); + APPROX_TOTAL_GAUGE.record(approx_count as u64, &[entity.as_kv()]); self.set_current_stage(ProgressStage::MigratingData { entity, counter: counter.clone(), @@ -99,7 +195,7 @@ impl Default for Progress { pub enum ProgressStage { SettingUp, MigratingData { - entity: &'static str, + entity: EntityType, counter: ProgressCounter, approx_count: u64, }, diff --git a/crates/syn2mas/src/synapse_reader/checks.rs b/crates/syn2mas/src/synapse_reader/checks.rs index 360e6d38d..0969d3787 100644 --- a/crates/syn2mas/src/synapse_reader/checks.rs +++ b/crates/syn2mas/src/synapse_reader/checks.rs @@ -73,6 +73,11 @@ pub enum CheckError { )] SynapseMissingOAuthProvider { provider: String, num_users: i64 }, + #[error( + "Synapse database has {num_users} mapping entries from a previously-configured MAS instance. If this is from a previous migration attempt, run the following SQL query against the Synapse database: `DELETE FROM user_external_ids WHERE auth_provider = 'oauth-delegated';` and then run the migration again." + )] + ExistingOAuthDelegated { num_users: i64 }, + #[error( "Synapse config contains an OpenID Connect or OAuth2 provider '{provider}' (issuer: {issuer:?}) used by {num_users} users which must also be configured in the MAS configuration as an upstream provider." )] @@ -157,7 +162,7 @@ pub fn synapse_config_check(synapse_config: &Config) -> (Vec, Vec< )); } - if synapse_config.enable_3pid_changes { + if synapse_config.enable_3pid_changes == Some(true) { errors.push(CheckError::ThreepidChangesEnabled); } @@ -292,6 +297,14 @@ pub async fn synapse_database_check( let syn_oauth2 = synapse.all_oidc_providers(); let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas)?; 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" { + errors.push(CheckError::ExistingOAuthDelegated { + num_users: row.num_users, + }); + continue; + } + let matching_syn = syn_oauth2.get(&row.auth_provider); let Some(matching_syn) = matching_syn else { diff --git a/crates/syn2mas/src/synapse_reader/config.rs b/crates/syn2mas/src/synapse_reader/config/mod.rs similarity index 62% rename from crates/syn2mas/src/synapse_reader/config.rs rename to crates/syn2mas/src/synapse_reader/config/mod.rs index 2c413a1b9..3abf2e567 100644 --- a/crates/syn2mas/src/synapse_reader/config.rs +++ b/crates/syn2mas/src/synapse_reader/config/mod.rs @@ -3,12 +3,21 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +mod oidc; + use std::collections::BTreeMap; use camino::Utf8PathBuf; +use chrono::{DateTime, Utc}; use figment::providers::{Format, Yaml}; +use mas_config::{PasswordAlgorithm, PasswordHashingScheme}; +use rand::Rng; use serde::Deserialize; use sqlx::postgres::PgConnectOptions; +use tracing::warn; +use url::Url; + +pub use self::oidc::OidcProvider; /// The root of a Synapse configuration. /// This struct only includes fields which the Synapse-to-MAS migration is @@ -16,13 +25,15 @@ use sqlx::postgres::PgConnectOptions; /// /// See: #[derive(Deserialize)] -#[allow(clippy::struct_excessive_bools)] +#[expect(clippy::struct_excessive_bools)] pub struct Config { pub database: DatabaseSection, #[serde(default)] pub password_config: PasswordSection, + pub bcrypt_rounds: Option, + #[serde(default)] pub allow_guest_access: bool, @@ -31,11 +42,16 @@ pub struct Config { #[serde(default)] pub enable_registration_captcha: bool, + pub recaptcha_public_key: Option, + pub recaptcha_private_key: Option, /// Normally this defaults to true, but when MAS integration is enabled in /// Synapse it defaults to false. #[serde(default)] - pub enable_3pid_changes: bool, + pub enable_3pid_changes: Option, + + #[serde(default = "default_true")] + enable_set_display_name: bool, #[serde(default)] pub user_consent: Option, @@ -67,6 +83,8 @@ pub struct Config { pub oidc_providers: Vec, pub server_name: String, + + pub public_baseurl: Option, } impl Config { @@ -100,21 +118,97 @@ impl Config { let mut out = BTreeMap::new(); if let Some(provider) = &self.oidc_config { - if provider.issuer.is_some() { + if provider.has_required_fields() { + let mut provider = provider.clone(); // The legacy configuration has an implied IdP ID of `oidc`. - out.insert("oidc".to_owned(), provider.clone()); + 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 { - if let Some(idp_id) = &provider.idp_id { + let mut provider = provider.clone(); + let idp_id = match provider.idp_id.take() { + None => "oidc".to_owned(), + Some(idp_id) if idp_id == "oidc" => idp_id, // Synapse internally prefixes the IdP IDs with `oidc-`. - out.insert(format!("oidc-{idp_id}"), provider.clone()); - } + Some(idp_id) => format!("oidc-{idp_id}"), + }; + provider.idp_id = Some(idp_id.clone()); + out.insert(idp_id, provider); } out } + + /// Adjust a MAS configuration to match this Synapse configuration. + #[must_use] + pub fn adjust_mas_config( + self, + mut mas_config: mas_config::RootConfig, + rng: &mut impl Rng, + now: DateTime, + ) -> mas_config::RootConfig { + let providers = self.all_oidc_providers(); + for provider in providers.into_values() { + let Some(mas_provider_config) = provider.into_mas_config(rng, now) else { + // TODO: better log message + warn!("Could not convert OIDC provider to MAS config"); + continue; + }; + + mas_config + .upstream_oauth2 + .providers + .push(mas_provider_config); + } + + // TODO: manage when the option is not set + if let Some(enable_3pid_changes) = self.enable_3pid_changes { + mas_config.account.email_change_allowed = enable_3pid_changes; + } + mas_config.account.displayname_change_allowed = self.enable_set_display_name; + if self.password_config.enabled { + mas_config.passwords.enabled = true; + mas_config.passwords.schemes = vec![ + // This is the password hashing scheme synapse uses + PasswordHashingScheme { + version: 1, + algorithm: PasswordAlgorithm::Bcrypt, + cost: self.bcrypt_rounds, + secret: self.password_config.pepper, + secret_file: None, + }, + // Use the default algorithm MAS uses as a second hashing scheme, so that users + // will get their password hash upgraded to a more modern algorithm over time + PasswordHashingScheme { + version: 2, + algorithm: PasswordAlgorithm::default(), + cost: None, + secret: None, + secret_file: None, + }, + ]; + + mas_config.account.password_registration_enabled = self.enable_registration; + } else { + mas_config.passwords.enabled = false; + } + + if self.enable_registration_captcha { + mas_config.captcha.service = Some(mas_config::CaptchaServiceKind::RecaptchaV2); + mas_config.captcha.site_key = self.recaptcha_public_key; + mas_config.captcha.secret_key = self.recaptcha_private_key; + } + + mas_config.matrix.homeserver = self.server_name; + if let Some(public_baseurl) = self.public_baseurl { + mas_config.matrix.endpoint = public_baseurl; + } + + mas_config + } } /// The `database` section of the Synapse configuration. @@ -144,13 +238,21 @@ impl DatabaseSection { /// environment variables) as Synapse normally runs, then the connection /// options may not be valid. /// - /// Returns `None` if this database configuration is not configured for - /// Postgres. - #[must_use] - pub fn to_sqlx_postgres(&self) -> Option { + /// # Errors + /// + /// Returns an error if this database configuration is invalid or + /// unsupported. + pub fn to_sqlx_postgres(&self) -> Result { if self.name != SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2 { - return None; + anyhow::bail!("syn2mas does not support the {} database driver", self.name); } + + if self.args.database.is_some() && self.args.dbname.is_some() { + anyhow::bail!( + "Only one of `database` and `dbname` may be specified in the Synapse database configuration, not both." + ); + } + let mut opts = PgConnectOptions::new().application_name("syn2mas-synapse"); if let Some(host) = &self.args.host { @@ -162,6 +264,9 @@ impl DatabaseSection { if let Some(dbname) = &self.args.dbname { opts = opts.database(dbname); } + if let Some(database) = &self.args.database { + opts = opts.database(database); + } if let Some(user) = &self.args.user { opts = opts.username(user); } @@ -169,7 +274,7 @@ impl DatabaseSection { opts = opts.password(password); } - Some(opts) + Ok(opts) } } @@ -181,6 +286,8 @@ pub struct DatabaseArgsSuboption { pub user: Option, pub password: Option, pub dbname: Option, + // This is a deperecated way of specifying the database name. + pub database: Option, pub host: Option, pub port: Option, } @@ -215,17 +322,6 @@ pub struct EnableableSection { pub enabled: bool, } -#[derive(Clone, Deserialize)] -pub struct OidcProvider { - /// At least for `oidc_config`, if the dict is present but left empty then - /// the config should be ignored, so this field must be optional. - pub issuer: Option, - - /// Required, except for the old `oidc_config` where this is implied to be - /// "oidc". - pub idp_id: Option, -} - fn default_true() -> bool { true } @@ -239,7 +335,7 @@ mod test { #[test] fn test_to_sqlx_postgres() { #[track_caller] - #[allow(clippy::needless_pass_by_value)] + #[expect(clippy::needless_pass_by_value)] fn assert_eq_options(config: DatabaseSection, uri: &str) { let config_connect_options = config .to_sqlx_postgres() @@ -274,7 +370,24 @@ mod test { args: DatabaseArgsSuboption::default(), } .to_sqlx_postgres() - .is_none() + .is_err() + ); + + // Only one of `database` and `dbname` may be specified + assert!( + DatabaseSection { + name: "psycopg2".to_owned(), + args: DatabaseArgsSuboption { + user: Some("synapse_user".to_owned()), + password: Some("verysecret".to_owned()), + dbname: Some("synapse_db".to_owned()), + database: Some("synapse_db".to_owned()), + host: Some("synapse-db.example.com".to_owned()), + port: Some(42), + }, + } + .to_sqlx_postgres() + .is_err() ); assert_eq_options( @@ -291,6 +404,7 @@ mod test { user: Some("synapse_user".to_owned()), password: Some("verysecret".to_owned()), dbname: Some("synapse_db".to_owned()), + database: None, host: Some("synapse-db.example.com".to_owned()), port: Some(42), }, diff --git a/crates/syn2mas/src/synapse_reader/config/oidc.rs b/crates/syn2mas/src/synapse_reader/config/oidc.rs new file mode 100644 index 000000000..9eea0a9be --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/config/oidc.rs @@ -0,0 +1,350 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE 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, +}; +use mas_iana::jose::JsonWebSignatureAlg; +use oauth2_types::scope::{OPENID, Scope, ScopeToken}; +use rand::Rng; +use serde::Deserialize; +use tracing::warn; +use ulid::Ulid; +use url::Url; + +#[derive(Clone, Deserialize, Default)] +enum UserMappingProviderModule { + #[default] + #[serde(rename = "synapse.handlers.oidc.JinjaOidcMappingProvider")] + Jinja, + + #[serde(rename = "synapse.handlers.oidc_handler.JinjaOidcMappingProvider")] + JinjaLegacy, + + #[serde(other)] + Other, +} + +#[derive(Clone, Deserialize, Default)] +struct UserMappingProviderConfig { + subject_template: Option, + subject_claim: Option, + localpart_template: Option, + display_name_template: Option, + email_template: Option, + + #[serde(default)] + confirm_localpart: bool, +} + +impl UserMappingProviderConfig { + fn into_mas_config(self) -> UpstreamOAuth2ClaimsImports { + let mut config = UpstreamOAuth2ClaimsImports::default(); + + match (self.subject_claim, self.subject_template) { + (Some(_), Some(subject_template)) => { + warn!( + "Both `subject_claim` and `subject_template` options are set, using `subject_template`." + ); + config.subject.template = Some(subject_template); + } + (None, Some(subject_template)) => { + config.subject.template = Some(subject_template); + } + (Some(subject_claim), None) => { + config.subject.template = Some(format!("{{{{ user.{subject_claim} }}}}")); + } + (None, None) => {} + } + + if let Some(localpart_template) = self.localpart_template { + config.localpart.template = Some(localpart_template); + config.localpart.action = if self.confirm_localpart { + UpstreamOAuth2ImportAction::Suggest + } else { + UpstreamOAuth2ImportAction::Require + }; + } + + if let Some(displayname_template) = self.display_name_template { + config.displayname.template = Some(displayname_template); + config.displayname.action = if self.confirm_localpart { + UpstreamOAuth2ImportAction::Suggest + } else { + UpstreamOAuth2ImportAction::Force + }; + } + + if let Some(email_template) = self.email_template { + config.email.template = Some(email_template); + config.email.action = if self.confirm_localpart { + UpstreamOAuth2ImportAction::Suggest + } else { + UpstreamOAuth2ImportAction::Force + }; + } + + config + } +} + +#[derive(Clone, Deserialize, Default)] +struct UserMappingProvider { + #[serde(default)] + module: UserMappingProviderModule, + #[serde(default)] + config: UserMappingProviderConfig, +} + +#[derive(Clone, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +enum PkceMethod { + #[default] + Auto, + Always, + Never, + #[serde(other)] + Other, +} + +#[derive(Clone, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +enum UserProfileMethod { + #[default] + Auto, + UserinfoEndpoint, + #[serde(other)] + Other, +} + +#[derive(Clone, Deserialize)] +#[expect(clippy::struct_excessive_bools)] +pub struct OidcProvider { + pub issuer: Option, + + /// Required, except for the old `oidc_config` where this is implied to be + /// "oidc". + pub idp_id: Option, + + idp_name: Option, + idp_brand: Option, + + #[serde(default = "default_true")] + discover: bool, + + client_id: Option, + client_secret: Option, + + // Unsupported, we want to shout about it + client_secret_path: Option, + + // Unsupported, we want to shout about it + client_secret_jwt_key: Option, + client_auth_method: Option, + #[serde(default)] + pkce_method: PkceMethod, + // Unsupported, we want to shout about it + id_token_signing_alg_values_supported: Option>, + scopes: Option>, + authorization_endpoint: Option, + token_endpoint: Option, + userinfo_endpoint: Option, + jwks_uri: Option, + #[serde(default)] + skip_verification: bool, + + // Unsupported, we want to shout about it + #[serde(default)] + backchannel_logout_enabled: bool, + + #[serde(default)] + user_profile_method: UserProfileMethod, + + // Unsupported, we want to shout about it + attribute_requirements: Option, + + // Unsupported, we want to shout about it + #[serde(default = "default_true")] + enable_registration: bool, + #[serde(default)] + additional_authorization_parameters: BTreeMap, + #[serde(default)] + forward_login_hint: bool, + #[serde(default)] + user_mapping_provider: UserMappingProvider, +} + +fn default_true() -> bool { + true +} + +impl OidcProvider { + /// Returns true if the two 'required' fields are set. This is used to + /// ignore an empty dict on the `oidc_config` section. + #[must_use] + pub(crate) fn has_required_fields(&self) -> bool { + self.issuer.is_some() && self.client_id.is_some() + } + + /// 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, + now: DateTime, + ) -> Option { + let client_id = self.client_id?; + + if self.client_secret_path.is_some() { + warn!( + "The `client_secret_path` option is not supported, ignoring. You *will* need to include the secret in the `client_secret` field." + ); + } + + if self.client_secret_jwt_key.is_some() { + warn!("The `client_secret_jwt_key` option is not supported, ignoring."); + } + + if self.attribute_requirements.is_some() { + warn!("The `attribute_requirements` option is not supported, ignoring."); + } + + if self.id_token_signing_alg_values_supported.is_some() { + 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." + ); + } + + let scope: Scope = match self.scopes { + None => [OPENID].into_iter().collect(), // Synapse defaults to the 'openid' scope + Some(scopes) => scopes + .into_iter() + .filter_map(|scope| match ScopeToken::from_str(&scope) { + Ok(scope) => Some(scope), + Err(err) => { + warn!("OIDC provider scope '{scope}' is invalid: {err}"); + None + } + }) + .collect(), + }; + + let id = Ulid::from_datetime_with_source(now.into(), rng); + + let token_endpoint_auth_method = self.client_auth_method.unwrap_or_else(|| { + // The token auth method defaults to 'none' if no client_secret is set and + // 'client_secret_basic' otherwise + if self.client_secret.is_some() { + UpstreamOAuth2TokenAuthMethod::ClientSecretBasic + } else { + UpstreamOAuth2TokenAuthMethod::None + } + }); + + let discovery_mode = match (self.discover, self.skip_verification) { + (true, false) => UpstreamOAuth2DiscoveryMode::Oidc, + (true, true) => UpstreamOAuth2DiscoveryMode::Insecure, + (false, _) => UpstreamOAuth2DiscoveryMode::Disabled, + }; + + let pkce_method = match self.pkce_method { + PkceMethod::Auto => UpstreamOAuth2PkceMethod::Auto, + PkceMethod::Always => UpstreamOAuth2PkceMethod::Always, + PkceMethod::Never => UpstreamOAuth2PkceMethod::Never, + PkceMethod::Other => { + warn!( + "The `pkce_method` option is not supported, expected 'auto', 'always', or 'never'; assuming 'auto'." + ); + UpstreamOAuth2PkceMethod::default() + } + }; + + // "auto" doesn't mean the same thing depending on whether we request the openid + // scope or not + let has_openid_scope = scope.contains(&OPENID); + let fetch_userinfo = match self.user_profile_method { + UserProfileMethod::Auto => has_openid_scope, + UserProfileMethod::UserinfoEndpoint => true, + UserProfileMethod::Other => { + warn!( + "The `user_profile_method` option is not supported, expected 'auto' or 'userinfo_endpoint'; assuming 'auto'." + ); + has_openid_scope + } + }; + + // Check if there is a `response_mode` set in the additional authorization + // parameters + let mut additional_authorization_parameters = self.additional_authorization_parameters; + let response_mode = if let Some(response_mode) = + additional_authorization_parameters.remove("response_mode") + { + match response_mode.to_ascii_lowercase().as_str() { + "query" => Some(UpstreamOAuth2ResponseMode::Query), + "form_post" => Some(UpstreamOAuth2ResponseMode::FormPost), + _ => { + warn!( + "Invalid `response_mode` in the `additional_authorization_parameters` option, expected 'query' or 'form_post'; ignoring." + ); + None + } + } + } else { + None + }; + + let claims_imports = if matches!( + self.user_mapping_provider.module, + UserMappingProviderModule::Other + ) { + warn!( + "The `user_mapping_provider` module specified is not supported, ignoring. Please adjust the `claims_imports` to match the mapping provider behaviour." + ); + UpstreamOAuth2ClaimsImports::default() + } else { + self.user_mapping_provider.config.into_mas_config() + }; + + Some(mas_config::UpstreamOAuth2Provider { + enabled: true, + id, + synapse_idp_id: self.idp_id, + issuer: self.issuer, + human_name: self.idp_name, + brand_name: self.idp_brand, + client_id, + client_secret: self.client_secret, + token_endpoint_auth_method, + sign_in_with_apple: None, + token_endpoint_auth_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + scope: scope.to_string(), + discovery_mode, + pkce_method, + fetch_userinfo, + userinfo_signed_response_alg: None, + authorization_endpoint: self.authorization_endpoint, + userinfo_endpoint: self.userinfo_endpoint, + token_endpoint: self.token_endpoint, + jwks_uri: self.jwks_uri, + response_mode, + claims_imports, + additional_authorization_parameters, + forward_login_hint: self.forward_login_hint, + }) + } +} 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 d9f9a4a7b..e92fd21bf 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/access_token_alice.sql @@ -1,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO access_tokens ( id, 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 6bdfb0d9c..c8b2850ac 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,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO access_tokens ( id, 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 554ae4458..180a58810 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,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO access_tokens ( id, 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 42bfddf01..8c7d1c695 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,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO access_tokens ( id, diff --git a/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql index c7f0691d6..8eb50a3ba 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/devices_alice.sql @@ -1,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO devices ( user_id, 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 5a00cebb5..a365faf05 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql @@ -1,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO user_external_ids ( user_id, diff --git a/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql index 526c00c2c..4bf680cce 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql @@ -1,3 +1,8 @@ +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO user_threepids ( user_id, diff --git a/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql index bf52d6c5c..dc77d5859 100644 --- a/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql +++ b/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql @@ -1,4 +1,8 @@ --- +-- Copyright 2024, 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + INSERT INTO users ( name, @@ -37,4 +41,3 @@ INSERT INTO users false, false ); - diff --git a/crates/syn2mas/src/telemetry.rs b/crates/syn2mas/src/telemetry.rs index 5c1c0a54a..e9a3385fb 100644 --- a/crates/syn2mas/src/telemetry.rs +++ b/crates/syn2mas/src/telemetry.rs @@ -1,3 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + use std::sync::LazyLock; use opentelemetry::{InstrumentationScope, metrics::Meter}; @@ -12,21 +17,3 @@ static SCOPE: LazyLock = LazyLock::new(|| { pub static METER: LazyLock = LazyLock::new(|| opentelemetry::global::meter_with_scope(SCOPE.clone())); - -/// Attribute key for syn2mas.entity metrics representing what entity. -pub const K_ENTITY: &str = "entity"; - -/// Attribute value for syn2mas.entity metrics representing users. -pub const V_ENTITY_USERS: &str = "users"; -/// Attribute value for syn2mas.entity metrics representing devices. -pub const V_ENTITY_DEVICES: &str = "devices"; -/// Attribute value for syn2mas.entity metrics representing threepids. -pub const V_ENTITY_THREEPIDS: &str = "threepids"; -/// Attribute value for syn2mas.entity metrics representing external IDs. -pub const V_ENTITY_EXTERNAL_IDS: &str = "external_ids"; -/// Attribute value for syn2mas.entity metrics representing non-refreshable -/// access token entities. -pub const V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS: &str = "nonrefreshable_access_tokens"; -/// Attribute value for syn2mas.entity metrics representing refreshable -/// access/refresh token pairs. -pub const V_ENTITY_REFRESHABLE_TOKEN_PAIRS: &str = "refreshable_token_pairs"; diff --git a/crates/tasks/Cargo.toml b/crates/tasks/Cargo.toml index 306e5cede..18eb740d5 100644 --- a/crates/tasks/Cargo.toml +++ b/crates/tasks/Cargo.toml @@ -30,6 +30,7 @@ ulid.workspace = true serde.workspace = true serde_json.workspace = true +mas-context.workspace = true mas-data-model.workspace = true mas-email.workspace = true mas-i18n.workspace = true diff --git a/crates/tasks/src/database.rs b/crates/tasks/src/database.rs index 24cfd43d8..fa424d7df 100644 --- a/crates/tasks/src/database.rs +++ b/crates/tasks/src/database.rs @@ -17,7 +17,7 @@ use crate::{ #[async_trait] impl RunnableJob for CleanupExpiredTokensJob { - #[tracing::instrument(name = "job.cleanup_expired_tokens", skip_all, err)] + #[tracing::instrument(name = "job.cleanup_expired_tokens", skip_all)] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let clock = state.clock(); let mut repo = state.repository().await.map_err(JobError::retry)?; @@ -41,7 +41,7 @@ impl RunnableJob for CleanupExpiredTokensJob { #[async_trait] impl RunnableJob for PruneStalePolicyDataJob { - #[tracing::instrument(name = "job.prune_stale_policy_data", skip_all, err)] + #[tracing::instrument(name = "job.prune_stale_policy_data", skip_all)] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let mut repo = state.repository().await.map_err(JobError::retry)?; diff --git a/crates/tasks/src/email.rs b/crates/tasks/src/email.rs index 25ab5b7c2..4eacdfaf6 100644 --- a/crates/tasks/src/email.rs +++ b/crates/tasks/src/email.rs @@ -23,7 +23,6 @@ impl RunnableJob for VerifyEmailJob { name = "job.verify_email", fields(user_email.id = %self.user_email_id()), skip_all, - err, )] async fn run(&self, _state: &State, _context: JobContext) -> Result<(), JobError> { // This job was for the old email verification flow, which has been replaced. @@ -39,7 +38,6 @@ impl RunnableJob for SendEmailAuthenticationCodeJob { name = "job.send_email_authentication_code", fields(user_email_authentication.id = %self.user_email_authentication_id()), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let clock = state.clock(); diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index 5ef7f5e84..cb1b16469 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -10,8 +10,8 @@ use mas_data_model::SiteConfig; use mas_email::Mailer; use mas_matrix::HomeserverConnection; use mas_router::UrlBuilder; -use mas_storage::{BoxClock, BoxRepository, RepositoryError, SystemClock}; -use mas_storage_pg::PgRepository; +use mas_storage::{BoxClock, BoxRepository, RepositoryError, RepositoryFactory, SystemClock}; +use mas_storage_pg::PgRepositoryFactory; use new_queue::QueueRunnerError; use opentelemetry::metrics::Meter; use rand::SeedableRng; @@ -37,7 +37,7 @@ static METER: LazyLock = LazyLock::new(|| { #[derive(Clone)] struct State { - pool: Pool, + repository_factory: PgRepositoryFactory, mailer: Mailer, clock: SystemClock, homeserver: Arc, @@ -47,7 +47,7 @@ struct State { impl State { pub fn new( - pool: Pool, + repository_factory: PgRepositoryFactory, clock: SystemClock, mailer: Mailer, homeserver: impl HomeserverConnection + 'static, @@ -55,7 +55,7 @@ impl State { site_config: SiteConfig, ) -> Self { Self { - pool, + repository_factory, mailer, clock, homeserver: Arc::new(homeserver), @@ -64,8 +64,8 @@ impl State { } } - pub fn pool(&self) -> &Pool { - &self.pool + pub fn pool(&self) -> Pool { + self.repository_factory.pool() } pub fn clock(&self) -> BoxClock { @@ -83,12 +83,7 @@ impl State { } pub async fn repository(&self) -> Result { - let repo = PgRepository::from_pool(self.pool()) - .await - .map_err(RepositoryError::from_error)? - .boxed(); - - Ok(repo) + self.repository_factory.create().await } pub fn matrix_connection(&self) -> &dyn HomeserverConnection { @@ -110,7 +105,7 @@ impl State { /// /// This function can fail if the database connection fails. pub async fn init( - pool: &Pool, + repository_factory: PgRepositoryFactory, mailer: &Mailer, homeserver: impl HomeserverConnection + 'static, url_builder: UrlBuilder, @@ -119,7 +114,7 @@ pub async fn init( task_tracker: &TaskTracker, ) -> Result<(), QueueRunnerError> { let state = State::new( - pool.clone(), + repository_factory, SystemClock::default(), mailer.clone(), homeserver, diff --git a/crates/tasks/src/matrix.rs b/crates/tasks/src/matrix.rs index d65152198..3060b3d7b 100644 --- a/crates/tasks/src/matrix.rs +++ b/crates/tasks/src/matrix.rs @@ -36,7 +36,6 @@ impl RunnableJob for ProvisionUserJob { name = "job.provision_user" fields(user.id = %self.user_id()), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let matrix = state.matrix_connection(); @@ -103,7 +102,6 @@ impl RunnableJob for ProvisionDeviceJob { device.id = %self.device_id(), ), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let mut repo = state.repository().await.map_err(JobError::retry)?; @@ -140,7 +138,6 @@ impl RunnableJob for DeleteDeviceJob { device.id = %self.device_id(), ), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let mut rng = state.rng(); @@ -172,7 +169,6 @@ impl RunnableJob for SyncDevicesJob { name = "job.sync_devices", fields(user.id = %self.user_id()), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let matrix = state.matrix_connection(); diff --git a/crates/tasks/src/new_queue.rs b/crates/tasks/src/new_queue.rs index b78d9014a..1c83b1720 100644 --- a/crates/tasks/src/new_queue.rs +++ b/crates/tasks/src/new_queue.rs @@ -8,6 +8,7 @@ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use cron::Schedule; +use mas_context::LogContext; use mas_storage::{ Clock, RepositoryAccess, RepositoryError, queue::{InsertableJob, Job, JobMetadata, Worker}, @@ -183,7 +184,7 @@ fn retry_delay(attempt: usize) -> Duration { Duration::milliseconds(2_i64.saturating_pow(attempt) * 5_000) } -type JobResult = Result<(), JobError>; +type JobResult = (std::time::Duration, Result<(), JobError>); type JobFactory = Arc Box + Send + Sync>; struct ScheduleDefinition { @@ -223,7 +224,7 @@ impl QueueWorker { let mut rng = state.rng(); let clock = state.clock(); - let mut listener = PgListener::connect_with(state.pool()) + let mut listener = PgListener::connect_with(&state.pool()) .await .map_err(QueueRunnerError::SetupListener)?; @@ -252,7 +253,7 @@ impl QueueWorker { .await .map_err(QueueRunnerError::CommitTransaction)?; - tracing::info!("Registered worker"); + tracing::info!(worker.id = %registration.id, "Registered worker"); let now = clock.now(); let wakeup_reason = METER @@ -337,7 +338,9 @@ impl QueueWorker { self.setup_schedules().await?; while !self.cancellation_token.is_cancelled() { - self.run_loop().await?; + LogContext::new("worker-run-loop") + .run(|| self.run_loop()) + .await?; } self.shutdown().await?; @@ -345,7 +348,7 @@ impl QueueWorker { Ok(()) } - #[tracing::instrument(name = "worker.setup_schedules", skip_all, err)] + #[tracing::instrument(name = "worker.setup_schedules", skip_all)] pub async fn setup_schedules(&mut self) -> Result<(), QueueRunnerError> { let schedules: Vec<_> = self.schedules.iter().map(|s| s.schedule_name).collect(); @@ -369,7 +372,7 @@ impl QueueWorker { Ok(()) } - #[tracing::instrument(name = "worker.run_loop", skip_all, err)] + #[tracing::instrument(name = "worker.run_loop", skip_all)] async fn run_loop(&mut self) -> Result<(), QueueRunnerError> { self.wait_until_wakeup().await?; @@ -390,7 +393,7 @@ impl QueueWorker { Ok(()) } - #[tracing::instrument(name = "worker.shutdown", skip_all, err)] + #[tracing::instrument(name = "worker.shutdown", skip_all)] async fn shutdown(&mut self) -> Result<(), QueueRunnerError> { tracing::info!("Shutting down worker"); @@ -435,7 +438,7 @@ impl QueueWorker { Ok(()) } - #[tracing::instrument(name = "worker.wait_until_wakeup", skip_all, err)] + #[tracing::instrument(name = "worker.wait_until_wakeup", skip_all)] async fn wait_until_wakeup(&mut self) -> Result<(), QueueRunnerError> { // 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 @@ -484,7 +487,6 @@ impl QueueWorker { name = "worker.tick", skip_all, fields(worker.id = %self.registration.id), - err, )] async fn tick(&mut self) -> Result<(), QueueRunnerError> { tracing::debug!("Tick"); @@ -583,7 +585,7 @@ impl QueueWorker { Ok(()) } - #[tracing::instrument(name = "worker.perform_leader_duties", skip_all, err)] + #[tracing::instrument(name = "worker.perform_leader_duties", skip_all)] async fn perform_leader_duties(&mut self) -> Result<(), QueueRunnerError> { // This should have been checked by the caller, but better safe than sorry if !self.am_i_leader { @@ -771,16 +773,86 @@ impl JobTracker { fn spawn_job(&mut self, state: State, context: JobContext, payload: JobPayload) { let factory = self.factories.get(context.queue_name.as_str()).cloned(); let task = { + let log_context = LogContext::new(format!("job-{}", context.queue_name)); let context = context.clone(); let span = context.span(); - async move { - // We should never crash, but in case we do, we do that in the task and - // don't crash the worker - let job = factory.expect("unknown job factory")(payload); - tracing::info!("Running job"); - job.run(&state, context).await - } - .instrument(span) + log_context + .run(async move || { + // We should never crash, but in case we do, we do that in the task and + // don't crash the worker + let job = factory.expect("unknown job factory")(payload); + tracing::info!( + job.id = %context.id, + job.queue.name = %context.queue_name, + job.attempt = %context.attempt, + "Running job" + ); + let result = job.run(&state, context.clone()).await; + + let Some(context_stats) = + LogContext::maybe_with(mas_context::LogContext::stats) + else { + // This should never happen, but if it does it's fine: we're recovering fine + // from panics in those tasks + panic!("Missing log context, this should never happen"); + }; + + // We log the result here so that it's attached to the right span & log context + match &result { + Ok(()) => { + tracing::info!( + job.id = %context.id, + job.queue.name = %context.queue_name, + job.attempt = %context.attempt, + "Job completed [{context_stats}]" + ); + } + + Err(JobError { + decision: JobErrorDecision::Fail, + error, + }) => { + tracing::error!( + error = &**error as &dyn std::error::Error, + job.id = %context.id, + job.queue.name = %context.queue_name, + job.attempt = %context.attempt, + "Job failed, not retrying [{context_stats}]" + ); + } + + Err(JobError { + decision: JobErrorDecision::Retry, + error, + }) if context.attempt < MAX_ATTEMPTS => { + let delay = retry_delay(context.attempt); + tracing::warn!( + error = &**error as &dyn std::error::Error, + job.id = %context.id, + job.queue.name = %context.queue_name, + job.attempt = %context.attempt, + "Job failed, will retry in {}s [{context_stats}]", + delay.num_seconds() + ); + } + + Err(JobError { + decision: JobErrorDecision::Retry, + error, + }) => { + tracing::error!( + error = &**error as &dyn std::error::Error, + job.id = %context.id, + job.queue.name = %context.queue_name, + job.attempt = %context.attempt, + "Job failed too many times, abandonning [{context_stats}]" + ); + } + } + + (context_stats.elapsed, result) + }) + .instrument(span) }; self.in_flight_jobs.add( @@ -837,15 +909,10 @@ impl JobTracker { } } - // XXX: the time measurement isn't accurate, as it would include the - // time spent between the task finishing, and us processing the result. - // It's fine for now, as it at least gives us an idea of how many tasks - // we run, and what their status is - while let Some(result) = self.last_join_result.take() { match result { - // The job succeeded - Ok((id, Ok(()))) => { + // The job succeeded. The logging and time measurement is already done in the task + Ok((id, (elapsed, Ok(())))) => { let context = self .job_contexts .remove(&id) @@ -856,22 +923,9 @@ impl JobTracker { &[KeyValue::new("job.queue.name", context.queue_name.clone())], ); - let elapsed = context - .start - .elapsed() - .as_millis() - .try_into() - .unwrap_or(u64::MAX); - tracing::info!( - job.id = %context.id, - job.queue.name = %context.queue_name, - job.attempt = %context.attempt, - job.elapsed = format!("{elapsed}ms"), - "Job completed" - ); - + let elapsed_ms = elapsed.as_millis().try_into().unwrap_or(u64::MAX); self.job_processing_time.record( - elapsed, + elapsed_ms, &[ KeyValue::new("job.queue.name", context.queue_name), KeyValue::new("job.result", "success"), @@ -883,8 +937,8 @@ impl JobTracker { .await?; } - // The job failed - Ok((id, Err(e))) => { + // The job failed. The logging and time measurement is already done in the task + Ok((id, (elapsed, Err(e)))) => { let context = self .job_contexts .remove(&id) @@ -900,26 +954,11 @@ impl JobTracker { .mark_as_failed(clock, context.id, &reason) .await?; - let elapsed = context - .start - .elapsed() - .as_millis() - .try_into() - .unwrap_or(u64::MAX); - + let elapsed_ms = elapsed.as_millis().try_into().unwrap_or(u64::MAX); match e.decision { JobErrorDecision::Fail => { - tracing::error!( - error = &e as &dyn std::error::Error, - job.id = %context.id, - job.queue.name = %context.queue_name, - job.attempt = %context.attempt, - job.elapsed = format!("{elapsed}ms"), - "Job failed, not retrying" - ); - self.job_processing_time.record( - elapsed, + elapsed_ms, &[ KeyValue::new("job.queue.name", context.queue_name), KeyValue::new("job.result", "failed"), @@ -928,50 +967,31 @@ impl JobTracker { ); } + JobErrorDecision::Retry if context.attempt < MAX_ATTEMPTS => { + self.job_processing_time.record( + elapsed_ms, + &[ + KeyValue::new("job.queue.name", context.queue_name), + KeyValue::new("job.result", "failed"), + KeyValue::new("job.decision", "retry"), + ], + ); + + let delay = retry_delay(context.attempt); + repo.queue_job() + .retry(&mut *rng, clock, context.id, delay) + .await?; + } + JobErrorDecision::Retry => { - if context.attempt < MAX_ATTEMPTS { - let delay = retry_delay(context.attempt); - tracing::warn!( - error = &e as &dyn std::error::Error, - job.id = %context.id, - job.queue.name = %context.queue_name, - job.attempt = %context.attempt, - job.elapsed = format!("{elapsed}ms"), - "Job failed, will retry in {}s", - delay.num_seconds() - ); - - self.job_processing_time.record( - elapsed, - &[ - KeyValue::new("job.queue.name", context.queue_name), - KeyValue::new("job.result", "failed"), - KeyValue::new("job.decision", "retry"), - ], - ); - - repo.queue_job() - .retry(&mut *rng, clock, context.id, delay) - .await?; - } else { - tracing::error!( - error = &e as &dyn std::error::Error, - job.id = %context.id, - job.queue.name = %context.queue_name, - job.attempt = %context.attempt, - job.elapsed = format!("{elapsed}ms"), - "Job failed too many times, abandonning" - ); - - self.job_processing_time.record( - elapsed, - &[ - KeyValue::new("job.queue.name", context.queue_name), - KeyValue::new("job.result", "failed"), - KeyValue::new("job.decision", "abandon"), - ], - ); - } + self.job_processing_time.record( + elapsed_ms, + &[ + KeyValue::new("job.queue.name", context.queue_name), + KeyValue::new("job.result", "failed"), + KeyValue::new("job.decision", "abandon"), + ], + ); } } } @@ -989,6 +1009,8 @@ impl JobTracker { &[KeyValue::new("job.queue.name", context.queue_name.clone())], ); + // This measurement is not accurate as it includes the time processing the jobs, + // but it's fine, it's only for panicked tasks let elapsed = context .start .elapsed() @@ -1003,7 +1025,7 @@ impl JobTracker { if context.attempt < MAX_ATTEMPTS { let delay = retry_delay(context.attempt); - tracing::warn!( + tracing::error!( error = &e as &dyn std::error::Error, job.id = %context.id, job.queue.name = %context.queue_name, diff --git a/crates/tasks/src/recovery.rs b/crates/tasks/src/recovery.rs index 658f1b9f7..9d68dad66 100644 --- a/crates/tasks/src/recovery.rs +++ b/crates/tasks/src/recovery.rs @@ -32,7 +32,6 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { user_recovery_session.email, ), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let clock = state.clock(); diff --git a/crates/tasks/src/user.rs b/crates/tasks/src/user.rs index 272111d17..4dfa081c4 100644 --- a/crates/tasks/src/user.rs +++ b/crates/tasks/src/user.rs @@ -27,7 +27,6 @@ impl RunnableJob for DeactivateUserJob { name = "job.deactivate_user" fields(user.id = %self.user_id(), erase = %self.hs_erase()), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let clock = state.clock(); @@ -118,7 +117,6 @@ impl RunnableJob for ReactivateUserJob { name = "job.reactivate_user", fields(user.id = %self.user_id()), skip_all, - err, )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { let matrix = state.matrix_connection(); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index b6661d540..54b2f193d 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -22,7 +22,7 @@ use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmailAuthentication, + UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, }; use mas_i18n::DataLocale; @@ -105,13 +105,17 @@ 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) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec where Self: Sized; } impl TemplateContext for () { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -144,15 +148,19 @@ impl std::ops::Deref for WithLanguage { } impl TemplateContext for WithLanguage { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec where Self: Sized, { - T::sample(now, rng) - .into_iter() - .map(|inner| WithLanguage { - lang: "en".into(), - inner, + locales + .iter() + .flat_map(|locale| { + T::sample(now, rng, locales) + .into_iter() + .map(move |inner| WithLanguage { + lang: locale.to_string(), + inner, + }) }) .collect() } @@ -168,11 +176,11 @@ pub struct WithCsrf { } impl TemplateContext for WithCsrf { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec where Self: Sized, { - T::sample(now, rng) + T::sample(now, rng, locales) .into_iter() .map(|inner| WithCsrf { csrf_token: "fake_csrf_token".into(), @@ -192,14 +200,14 @@ pub struct WithSession { } impl TemplateContext for WithSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec where Self: Sized, { BrowserSession::samples(now, rng) .into_iter() .flat_map(|session| { - T::sample(now, rng) + T::sample(now, rng, locales) .into_iter() .map(move |inner| WithSession { current_session: session.clone(), @@ -220,7 +228,7 @@ pub struct WithOptionalSession { } impl TemplateContext for WithOptionalSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -229,7 +237,7 @@ impl TemplateContext for WithOptionalSession { .map(Some) // Wrap all samples in an Option .chain(std::iter::once(None)) // Add the "None" option .flat_map(|session| { - T::sample(now, rng) + T::sample(now, rng, locales) .into_iter() .map(move |inner| WithOptionalSession { current_session: session.clone(), @@ -257,7 +265,11 @@ impl Serialize for EmptyContext { } impl TemplateContext for EmptyContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -281,7 +293,11 @@ impl IndexContext { } impl TemplateContext for IndexContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -323,7 +339,11 @@ impl AppContext { } impl TemplateContext for AppContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -352,7 +372,11 @@ impl ApiDocContext { } impl TemplateContext for ApiDocContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -440,7 +464,11 @@ pub struct LoginContext { } impl TemplateContext for LoginContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -544,7 +572,11 @@ pub struct RegisterContext { } impl TemplateContext for RegisterContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -583,7 +615,11 @@ pub struct PasswordRegisterContext { } impl TemplateContext for PasswordRegisterContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -621,7 +657,7 @@ pub struct ConsentContext { } impl TemplateContext for ConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -673,7 +709,7 @@ pub struct PolicyViolationContext { } impl TemplateContext for PolicyViolationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -742,7 +778,7 @@ pub struct CompatSsoContext { } impl TemplateContext for CompatSsoContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -800,7 +836,7 @@ impl EmailRecoveryContext { } impl TemplateContext for EmailRecoveryContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -808,7 +844,7 @@ impl TemplateContext for EmailRecoveryContext { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "hello@example.com".to_owned(), - user_agent: UserAgent::parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) AppleWebKit/536.30.1 (KHTML, like Gecko) Version/6.0.5 Safari/536.30.1".to_owned()), + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) AppleWebKit/536.30.1 (KHTML, like Gecko) Version/6.0.5 Safari/536.30.1".to_owned(), ip_address: Some(IpAddr::from([192_u8, 0, 2, 1])), locale: "en".to_owned(), created_at: now, @@ -861,7 +897,7 @@ impl EmailVerificationContext { } impl TemplateContext for EmailVerificationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -927,7 +963,7 @@ impl RegisterStepsVerifyEmailContext { } impl TemplateContext for RegisterStepsVerifyEmailContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -963,7 +999,11 @@ impl RegisterStepsEmailInUseContext { } impl TemplateContext for RegisterStepsEmailInUseContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -1014,7 +1054,11 @@ impl RegisterStepsDisplayNameContext { } impl TemplateContext for RegisterStepsDisplayNameContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -1061,7 +1105,11 @@ impl RecoveryStartContext { } impl TemplateContext for RecoveryStartContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -1099,14 +1147,14 @@ impl RecoveryProgressContext { } impl TemplateContext for RecoveryProgressContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "name@mail.com".to_owned(), - user_agent: UserAgent::parse("Mozilla/5.0".to_owned()), + user_agent: "Mozilla/5.0".to_owned(), ip_address: None, locale: "en".to_owned(), created_at: now, @@ -1141,14 +1189,14 @@ impl RecoveryExpiredContext { } impl TemplateContext for RecoveryExpiredContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "name@mail.com".to_owned(), - user_agent: UserAgent::parse("Mozilla/5.0".to_owned()), + user_agent: "Mozilla/5.0".to_owned(), ip_address: None, locale: "en".to_owned(), created_at: now, @@ -1202,7 +1250,7 @@ impl RecoveryFinishContext { } impl TemplateContext for RecoveryFinishContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -1245,7 +1293,7 @@ impl UpstreamExistingLinkContext { } impl TemplateContext for UpstreamExistingLinkContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -1277,7 +1325,7 @@ impl UpstreamSuggestLink { } impl TemplateContext for UpstreamSuggestLink { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -1402,7 +1450,7 @@ impl UpstreamRegister { } impl TemplateContext for UpstreamRegister { - fn sample(now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -1437,6 +1485,7 @@ impl TemplateContext for UpstreamRegister { pkce_mode: UpstreamOAuthProviderPkceMode::Auto, response_mode: None, additional_authorization_parameters: Vec::new(), + forward_login_hint: false, created_at: now, disabled_at: None, }, @@ -1482,7 +1531,11 @@ impl DeviceLinkContext { } impl TemplateContext for DeviceLinkContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -1512,7 +1565,7 @@ impl DeviceConsentContext { } impl TemplateContext for DeviceConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -1529,7 +1582,7 @@ impl TemplateContext for DeviceConsentContext { 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))), - user_agent: Some(UserAgent::parse("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())), + 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 } }) @@ -1553,7 +1606,7 @@ impl AccountInactiveContext { } impl TemplateContext for AccountInactiveContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { @@ -1564,6 +1617,39 @@ impl TemplateContext for AccountInactiveContext { } } +/// Context used by the `device_name.txt` template +#[derive(Serialize)] +pub struct DeviceNameContext { + client: Client, + raw_user_agent: String, +} + +impl DeviceNameContext { + /// Constructs a new context with a client and user agent + #[must_use] + pub fn new(client: Client, user_agent: Option) -> Self { + Self { + client, + raw_user_agent: user_agent.unwrap_or_default(), + } + } +} + +impl TemplateContext for DeviceNameContext { + fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + where + Self: Sized, + { + 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() + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { @@ -1572,11 +1658,11 @@ pub struct FormPostContext { } impl TemplateContext for FormPostContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec where Self: Sized, { - let sample_params = T::sample(now, rng); + let sample_params = T::sample(now, rng, locales); sample_params .into_iter() .map(|params| FormPostContext { @@ -1645,7 +1731,11 @@ impl std::fmt::Display for ErrorContext { } impl TemplateContext for ErrorContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + fn sample( + _now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> Vec where Self: Sized, { @@ -1735,7 +1825,7 @@ impl NotFoundContext { } impl TemplateContext for NotFoundContext { - fn sample(_now: DateTime, _rng: &mut impl Rng) -> Vec + fn sample(_now: DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec where Self: Sized, { diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index 4e8c9f726..38d723ca0 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -6,6 +6,7 @@ use std::sync::Arc; +use mas_i18n::DataLocale; use minijinja::{ Value, value::{Enumerator, Object}, @@ -60,11 +61,12 @@ impl TemplateContext for WithCaptcha { fn sample( now: chrono::DateTime, rng: &mut impl rand::prelude::Rng, + locales: &[DataLocale], ) -> Vec where Self: Sized, { - let inner = T::sample(now, rng); + let inner = T::sample(now, rng, locales); inner .into_iter() .map(|inner| Self::new(None, inner)) diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index edda9783a..3229cde28 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -40,6 +40,7 @@ pub fn register( env.add_filter("to_params", filter_to_params); env.add_filter("simplify_url", filter_simplify_url); env.add_filter("add_slashes", filter_add_slashes); + env.add_filter("parse_user_agent", filter_parse_user_agent); env.add_function("add_params_to_url", function_add_params_to_url); env.add_function("counter", || Ok(Value::from_object(Counter::default()))); env.add_global( @@ -133,6 +134,12 @@ fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result Value { + let user_agent = mas_data_model::UserAgent::parse(user_agent); + Value::from_serialize(user_agent) +} + enum ParamsWhere { Fragment, Query, diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 704c55248..431b1f52b 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -35,13 +35,13 @@ mod macros; pub use self::{ context::{ AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext, - DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, - EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, - LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext, - PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext, - RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, - RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, - RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, + EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext, + FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, + PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, + RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, + RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, + RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, @@ -138,7 +138,6 @@ impl Templates { name = "templates.load", skip_all, fields(%path), - err, )] pub async fn load( path: Utf8PathBuf, @@ -258,7 +257,6 @@ impl Templates { name = "templates.reload", skip_all, fields(path = %self.path), - err, )] pub async fn reload(&self) -> Result<(), TemplateLoadingError> { let (translator, environment) = Self::load_( @@ -419,6 +417,9 @@ register_templates! { /// Render the 'account logged out' page pub fn render_account_logged_out(WithLanguage>) { "pages/account/logged_out.html" } + + /// Render the automatic device name for OAuth 2.0 client + pub fn render_device_name(WithLanguage) { "device_name.txt" } } impl Templates { @@ -455,12 +456,21 @@ impl Templates { 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(()) } } diff --git a/crates/templates/src/macros.rs b/crates/templates/src/macros.rs index b3ed9d60a..a3166b2bb 100644 --- a/crates/templates/src/macros.rs +++ b/crates/templates/src/macros.rs @@ -75,11 +75,12 @@ macro_rules! register_templates { /// # Errors /// /// Returns an error if the template fails to render with any of the sample. - pub fn $name + pub(crate) fn $name $(< $( $lt $( : $clt $(+ $dlt )* + TemplateContext )? ),+ >)? (templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) -> anyhow::Result<()> { - let samples: Vec< $param > = TemplateContext::sample(now, rng); + let locales = templates.translator().available_locales(); + let samples: Vec< $param > = TemplateContext::sample(now, rng, &locales); let name = $template; for sample in samples { diff --git a/crates/tower/Cargo.toml b/crates/tower/Cargo.toml index 978eaa3c1..52ef9da13 100644 --- a/crates/tower/Cargo.toml +++ b/crates/tower/Cargo.toml @@ -19,4 +19,4 @@ tower.workspace = true opentelemetry.workspace = true opentelemetry-http.workspace = true opentelemetry-semantic-conventions.workspace = true -pin-project-lite = "0.2.16" +pin-project-lite.workspace = true diff --git a/docker-bake.hcl b/docker-bake.hcl index 0df1c848f..cfa6b8353 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -4,12 +4,11 @@ variable "VERGEN_GIT_DESCRIBE" {} // This is what is baked by GitHub Actions -group "default" { targets = ["regular", "debug", "syn2mas"] } +group "default" { targets = ["regular", "debug"] } // Targets filled by GitHub Actions: one for the regular tag, one for the debug tag target "docker-metadata-action" {} target "docker-metadata-action-debug" {} -target "docker-metadata-action-syn2mas" {} // This sets the platforms and is further extended by GitHub Actions to set the // output and the cache locations @@ -37,8 +36,3 @@ target "debug" { inherits = ["base", "docker-metadata-action-debug"] target = "debug" } - -target "syn2mas" { - inherits = ["base", "docker-metadata-action-syn2mas"] - context = "./tools/syn2mas" -} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 782d71bfd..0b623e7b2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -12,7 +12,6 @@ - [Database setup](./setup/database.md) - [Homeserver configuration](./setup/homeserver.md) - [Configuring a reverse proxy](./setup/reverse-proxy.md) -- [Configuring .well-known](./setup/well-known.md) - [Configure an upstream SSO provider](./setup/sso.md) - [Running the service](./setup/running.md) - [Migrating an existing homeserver](./setup/migration.md) @@ -33,6 +32,7 @@ - [`database`](./reference/cli/database.md) - [`manage`](./reference/cli/manage.md) - [`server`](./reference/cli/server.md) + - [`syn2mas`](./reference/cli/syn2mas.md) - [`worker`](./reference/cli/worker.md) - [`templates`](./reference/cli/templates.md) - [`doctor`](./reference/cli/doctor.md) diff --git a/docs/api/spec.json b/docs/api/spec.json index 121022809..5b479b942 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -132,7 +132,8 @@ "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", "last_active_ip": "1.2.3.4", - "finished_at": null + "finished_at": null, + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" @@ -150,7 +151,8 @@ "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" + "finished_at": "1970-01-01T00:00:00Z", + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" @@ -168,7 +170,8 @@ "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/030C1G60R30C1G60R30C1G60R3" @@ -245,7 +248,8 @@ "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", "last_active_ip": "1.2.3.4", - "finished_at": null + "finished_at": null, + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" @@ -430,7 +434,8 @@ "scope": "openid", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", - "last_active_ip": "127.0.0.1" + "last_active_ip": "127.0.0.1", + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" @@ -448,7 +453,8 @@ "scope": "urn:mas:admin", "user_agent": null, "last_active_at": null, - "last_active_ip": null + "last_active_ip": null, + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/02081040G2081040G2081040G2" @@ -466,7 +472,8 @@ "scope": "urn:matrix:org.matrix.msc2967.client:api:*", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", - "last_active_ip": "127.0.0.1" + "last_active_ip": "127.0.0.1", + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" @@ -560,7 +567,8 @@ "scope": "openid", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", - "last_active_ip": "127.0.0.1" + "last_active_ip": "127.0.0.1", + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" @@ -2519,21 +2527,26 @@ "type": "oauth2", "flows": { "clientCredentials": { - "refreshUrl": "/oauth2/token", - "tokenUrl": "/oauth2/token", + "refreshUrl": "./oauth2/token", + "tokenUrl": "./oauth2/token", "scopes": { "urn:mas:admin": "Grant access to the admin API" } }, "authorizationCode": { - "authorizationUrl": "/authorize", - "tokenUrl": "/oauth2/token", - "refreshUrl": "/oauth2/token", + "authorizationUrl": "./authorize", + "tokenUrl": "./oauth2/token", + "refreshUrl": "./oauth2/token", "scopes": { "urn:mas:admin": "Grant access to the admin API" } } } + }, + "token": { + "type": "http", + "scheme": "bearer", + "description": "An access token with access to the admin API" } }, "schemas": { @@ -2726,6 +2739,11 @@ "type": "string", "format": "date-time", "nullable": true + }, + "human_name": { + "description": "The user-provided name, if any", + "type": "string", + "nullable": true } } }, @@ -3001,6 +3019,11 @@ "type": "string", "format": "ip", "nullable": true + }, + "human_name": { + "description": "The user-provided name, if any", + "type": "string", + "nullable": true } } }, @@ -3707,6 +3730,11 @@ "oauth2": [ "urn:mas:admin" ] + }, + { + "bearer": [ + "urn:mas:admin" + ] } ], "tags": [ diff --git a/docs/config.schema.json b/docs/config.schema.json index 267a03d7e..da0a58f60 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -239,6 +239,10 @@ } ] }, + "client_name": { + "description": "Name of the `OAuth2` client", + "type": "string" + }, "client_secret": { "description": "The client secret, used by the `client_secret_basic`, `client_secret_post` and `client_secret_jwt` authentication methods", "type": "string" @@ -1566,6 +1570,7 @@ "type": "boolean" }, "schemes": { + "description": "The hashing schemes to use for hashing and validating passwords\n\nThe hashing scheme with the highest version number will be used for hashing new passwords.", "default": [ { "version": 1, @@ -1587,6 +1592,7 @@ } }, "HashingScheme": { + "description": "Parameters for a password hashing scheme", "type": "object", "required": [ "algorithm", @@ -1594,12 +1600,18 @@ ], "properties": { "version": { + "description": "The version of the hashing scheme. They must be unique, and the highest version will be used for hashing new passwords.", "type": "integer", "format": "uint16", "minimum": 0.0 }, "algorithm": { - "$ref": "#/definitions/Algorithm" + "description": "The hashing algorithm to use", + "allOf": [ + { + "$ref": "#/definitions/Algorithm" + } + ] }, "cost": { "description": "Cost for the bcrypt algorithm", @@ -1609,9 +1621,11 @@ "minimum": 0.0 }, "secret": { + "description": "An optional secret to use when hashing passwords. This makes it harder to brute-force the passwords in case of a database leak.", "type": "string" }, "secret_file": { + "description": "Same as `secret`, but read from a file.", "type": "string" } } @@ -1960,7 +1974,6 @@ "required": [ "client_id", "id", - "scope", "token_endpoint_auth_method" ], "properties": { @@ -1973,6 +1986,10 @@ "type": "string", "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$" }, + "synapse_idp_id": { + "description": "The ID of the provider that was used by Synapse. In order to perform a Synapse-to-MAS migration, this must be specified.\n\n## For providers that used OAuth 2.0 or OpenID Connect in Synapse\n\n### For `oidc_providers`: This should be specified as `oidc-` followed by the ID that was configured as `idp_id` in one of the `oidc_providers` in the Synapse configuration. For example, if Synapse's configuration contained `idp_id: wombat` for this provider, then specify `oidc-wombat` here.\n\n### For `oidc_config` (legacy): Specify `oidc` here.", + "type": "string" + }, "issuer": { "description": "The OIDC issuer URL\n\nThis is required if OIDC discovery is enabled (which is the default)", "type": "string" @@ -2026,7 +2043,7 @@ ] }, "scope": { - "description": "The scopes to request from the provider", + "description": "The scopes to request from the provider\n\nDefaults to `openid`.", "type": "string" }, "discovery_mode": { @@ -2101,9 +2118,10 @@ "type": "string" } }, - "synapse_idp_id": { - "description": "The ID of the provider that was used by Synapse. In order to perform a Synapse-to-MAS migration, this must be specified.\n\n## For providers that used OAuth 2.0 or OpenID Connect in Synapse\n\n### For `oidc_providers`: This should be specified as `oidc-` followed by the ID that was configured as `idp_id` in one of the `oidc_providers` in the Synapse configuration. For example, if Synapse's configuration contained `idp_id: wombat` for this provider, then specify `oidc-wombat` here.\n\n### For `oidc_config` (legacy): Specify `oidc` here.", - "type": "string" + "forward_login_hint": { + "description": "Whether the `login_hint` should be forwarded to the provider in the authorization request.\n\nDefaults to `false`.", + "default": false, + "type": "boolean" } } }, diff --git a/docs/reference/cli/config.md b/docs/reference/cli/config.md index 551c61d76..c624838c1 100644 --- a/docs/reference/cli/config.md +++ b/docs/reference/cli/config.md @@ -26,7 +26,7 @@ clients: # ... ``` -## `config generate` +## `config generate [--synapse-config ] [--output ]` Generate a sample configuration file. It generates random signing keys (`.secrets.keys`) and the cookie encryption secret (`.secrets.encryption`). @@ -38,6 +38,10 @@ INFO generate:rsa: mas_config::oauth2: Done generating RSA key INFO generate:ecdsa: mas_config::oauth2: Done generating ECDSA key ``` +The `--synapse-config` option can be used to migrate over configuration options from an existing Synapse configuration. + +The `--output` option can be used to specify the output file. If not specified, the output will be written to stdout. + ## `config sync [--prune] [--dry-run]` Synchronize the configuration with the database. @@ -52,4 +56,4 @@ INFO cli.config.sync: Updating provider provider.id=01H3FDH2XZJS8ADKRGWM84PZTY INFO cli.config.sync: Adding provider provider.id=01H3FDH2XZJS8ADKRGWM84PZTF INFO cli.config.sync: Deleting client client.id=01GFWRB9MYE0QYK60NZP2YF905 INFO cli.config.sync: Updating client client.id=01GFWRB9MYE0QYK60NZP2YF904 -``` \ No newline at end of file +``` diff --git a/docs/reference/cli/syn2mas.md b/docs/reference/cli/syn2mas.md new file mode 100644 index 000000000..0089cc704 --- /dev/null +++ b/docs/reference/cli/syn2mas.md @@ -0,0 +1,29 @@ +# `syn2mas` + +Tool to import data from an existing Synapse homeserver into MAS. + +Global options: +- `--config `: Path to the MAS configuration file. +- `--help`: Print help. +- `--synapse-config `: Path to the Synapse configuration file. +- `--synapse-database-uri `: Override the Synapse database URI. + +## `syn2mas check` + +Check the setup for potential problems before running a migration + +```console +$ mas-cli syn2mas check --config mas_config.yaml --synapse-config homeserver.yaml +``` + +## `syn2mas migrate [--dry-run]` + +Migrate data from the homeserver to MAS. + +The `--dry-run` option will perform a dry-run of the migration, which is safe to run without stopping Synapse. +It will perform a full data migration, but then empty the MAS database at the end to roll back. + + +```console +$ mas-cli syn2mas migrate --config mas_config.yaml --synapse-config homeserver.yaml +``` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 30dbbfca9..2303e889e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -560,7 +560,7 @@ telemetry: dsn: https://public@host:port/1 ``` -### `email` +## `email` Settings related to sending emails @@ -583,19 +583,15 @@ email: # Send emails by calling a local sendmail binary #transport: sendmail #command: /usr/sbin/sendmail - - # Send emails through the AWS SESv2 API - # This uses the AWS SDK, so the usual AWS environment variables are supported - #transport: aws_ses ``` -### `upstream_oauth2` +## `upstream_oauth2` Settings related to upstream OAuth 2.0/OIDC providers. Additions and modifications within this section are synced with the database on server startup. Removed entries are only removed with the [`config sync --prune`](./cli/config.md#config-sync---prune---dry-run) command. -#### `upstream_oauth2.providers` +### `upstream_oauth2.providers` A list of upstream OAuth 2.0/OIDC providers to use to authenticate users. @@ -716,6 +712,10 @@ upstream_oauth2: # Additional parameters to include in the authorization request #additional_authorization_parameters: # foo: "bar" + + # Whether the `login_hint` should be forwarded to the provider in the + # authorization request. + #forward_login_hint: false # How user attributes should be mapped # diff --git a/docs/setup/README.md b/docs/setup/README.md index 6ff08d2bf..54b8ae8b4 100644 --- a/docs/setup/README.md +++ b/docs/setup/README.md @@ -11,43 +11,11 @@ The authentication service becomes the source of truth for user accounts and acc At time of writing, the authentication service is meant to be run on a standalone domain name (e.g. `auth.example.com`), and the homeserver on another (e.g. `matrix.example.com`). This domain will be user-facing as part of the authentication flow. -When a client initiates an authentication flow, it will discover the authentication service through the deployment `.well-known/matrix/client` endpoint. -This file will refer to an `issuer`, which is the canonical name of the authentication service instance. -Out of that issuer, it will discover the rest of the endpoints by calling the `[issuer]/.well-known/openid-configuration` endpoint. -By default, the `issuer` will match the root domain where the service is deployed (e.g. `https://auth.example.com/`), but it can be configured to be different. - An example setup could look like this: - The deployment domain is `example.com`, so Matrix IDs look like `@user:example.com` - - The issuer chosen is `https://auth.example.com/` - - The homeserver is deployed on `matrix.example.com` - The authentication service is deployed on `auth.example.com` - - Calling `https://example.com/.well-known/matrix/client` returns the following JSON: - - ```json - { - "m.homeserver": { - "base_url": "https://matrix.example.com" - }, - "org.matrix.msc2965.authentication": { - "issuer": "https://auth.example.com/", - "account": "https://auth.example.com/account" - } - } - ``` - - - Calling `https://auth.example.com/.well-known/openid-configuration` returns a JSON document similar to the following: - - ```json - { - "issuer": "https://auth.example.com/", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/oauth2/token", - "jwks_uri": "https://auth.example.com/oauth2/keys.json", - "registration_endpoint": "https://auth.example.com/oauth2/registration", - "//": "..." - } - ``` + - The homeserver is deployed on `matrix.example.com` With the installation planned, it is time to go through the installation and configuration process. The first section focuses on [installing the service](./installation.md). diff --git a/docs/setup/migration.md b/docs/setup/migration.md index 2d3b7a0c0..a10a9bbd1 100644 --- a/docs/setup/migration.md +++ b/docs/setup/migration.md @@ -1,51 +1,54 @@ # Migrating an existing homeserver -One of the design goals of MAS has been to allow it to be used to migrate an existing homeserver to an OIDC-based architecture. +One of the design goals of MAS has been to allow it to be used to migrate an existing homeserver, specifically without requiring users to re-authenticate and ensuring that all existing clients continue to work. -Specifically without requiring users to re-authenticate and that non-OIDC clients continue to work. - -Features that are provided to support this include: +Features that support this include: - Ability to import existing password hashes from Synapse - Ability to import existing sessions and devices -- Ability to import existing access tokens linked to devices (ie not including short-lived admin puppeted access tokens) +- Ability to import existing access tokens - Ability to import existing upstream IdP subject ID mappings - Provides a compatibility layer for legacy Matrix authentication -There will be tools to help with the migration process itself. But these aren't quite ready yet. - ## Preparing for the migration -The deployment is non-trivial so it is important to read through and understand the steps involved and make a plan before starting. +The deployment is non-trivial, so it is important to read through and understand the steps involved and make a plan before starting. -### Get `syn2mas` +### Is your setup ready to be migrated? -The easiest way to get `syn2mas` is through [`npm`](https://www.npmjs.com/package/@vector-im/syn2mas): +#### SAML2 and LDAP Single Sign-On Providers are not supported -```sh -npm install -g @vector-im/syn2mas -``` +A deployment that requires SAML or LDAP-based authentication should use a service like [Dex](https://github.com/dexidp/dex) to bridge between the SAML provider and the authentication service. +MAS differs from Synapse in that it does **not** have built-in support for SAML or LDAP-based providers. -### Run the migration advisor +#### Custom password providers are not supported -You can use the advisor mode of the `syn2mas` tool to identify extra configuration steps or issues with the configuration of the homeserver. +If your Synapse homeserver currently uses a custom password provider module, please note that MAS does not support these. -```sh -syn2mas --command=advisor --synapseConfigFile=homeserver.yaml -``` +#### SQLite databases are not supported -This will output `WARN` entries for any identified actions and `ERROR` entries in the case of any issues that will prevent the migration from working. +It is worth noting that MAS currently only supports PostgreSQL as a database backend. +The migration tool only supports reading from PostgreSQL for the Synapse database as well. ### Install and configure MAS alongside your existing homeserver Follow the instructions in the [installation guide](installation.md) to install MAS alongside your existing homeserver. +You'll need a blank PostgreSQL database for MAS to use; it does not share the database with the homeserver. + +MAS provides a tool to generate a configuration file based on your existing Synapse configuration. This is useful for kickstarting your new configuration. + +```sh +mas-cli config generate --synapse-config homeserver.yaml --output mas_config.yaml +``` + +When using this tool, be careful to examine the log output for any warnings about unsupported configuration options. + #### Local passwords -Synapse uses bcrypt as its password hashing scheme while MAS defaults to using the newer argon2id. +Synapse uses bcrypt as its password hashing scheme, while MAS defaults to using the newer argon2id. You will have to configure the version 1 scheme as bcrypt for migrated passwords to work. -It is also recommended that you keep argon2id as version 2 so that once users log in, their hashes will be updated to the newer recommended scheme. -If you have a `pepper` set in the `password_config` section of your Synapse config, then you need to specify this `pepper` as the `secret` field for your `bcrypt` scheme. +It is also recommended that you keep argon2id as version 2 so that once users log in, their hashes will be updated to the newer, recommended scheme. Example passwords configuration: ```yml @@ -60,57 +63,143 @@ passwords: algorithm: argon2id ``` +If you have a pepper configured in your Synapse password configuration, you'll need to match that on version 1 of the equivalent MAS configuration. + +The migration checker will inform you if this has not been configured properly. + ### Map any upstream SSO providers -If you are using an upstream SSO provider then you will need to provision the upstream provide in MAS manually. +If you are using an upstream SSO provider, then you will need to configure the upstream provider in MAS manually. -Each upstream provider will need to be given as an `--upstreamProviderMapping` command line option to the import tool. +MAS does not support SAML or LDAP upstream providers. +If you are using one of these, you will need to use an adapter such as Dex at this time, but we have not yet documented this procedure. -### Prepare the MAS database +Each upstream provider that was used by at least one user in Synapse will need to be configured in MAS. -Once the database is created, it still needs to have its schema created and synced with the configuration. -This can be done with the following command: +Set the `synapse_idp_id` attribute on the provider to: -```sh -mas-cli config sync +- `"oidc"` if you used an OIDC provider in Synapse's legacy `oidc_config` configuration section. +- `"oidc-myprovider"` if you used an OIDC provider in Synapse's `oidc_providers` configuration list, with a `provider` of `"myprovider"`. + (This is because Synapse prefixes the provider ID with `oidc-` internally.) + +Without the `synapse_idp_id`s being set, `mas-cli syn2mas` does not understand which providers in Synapse correspond to which provider in MAS. + +For example, if your Synapse configuration looked like this: + +```yaml +oidc_providers: + - idp_id: dex + idp_name: "My Dex server" + issuer: "https://example.com/dex" + client_id: "synapse" + client_secret: "supersecret" + scopes: ["openid", "profile", "email"] + user_mapping_provider: + config: + localpart_template: "{{ user.email.split('@')[0].lower() }}" + email_template: "{{ user.email }}" + display_name_template: "{{ user.name|capitalize }}" ``` -### Do a dry-run of the import to test +Then the equivalent configuration in MAS would look like this: -```sh -syn2mas --command migrate --synapseConfigFile homeserver.yaml --masConfigFile config.yaml --dryRun +```yaml +upstream_oauth2: + providers: + - id: 01JSHPZHAXC50QBKH67MH33TNF + synapse_idp_id: oidc-dex + issuer: "https://example.com/dex" + human_name: "My Dex server" + client_id: "synapse" + client_secret: "supersecret" + token_endpoint_auth_method: client_secret_basic + scope: "email openid profile" + claims_imports: + localpart: + action: require + template: "{{ user.email.split('@')[0].lower() }}" + displayname: + action: force + template: "{{ user.name|capitalize }}" + email: + action: force + template: "{{ user.email }}" ``` -If no errors are reported then you can proceed to the next step. +The migration checker will inform you if a provider is missing from MAS' config. + +### Run the migration checker + +You can use the `check` command of the `syn2mas` tool to identify configuration problems before starting the migration. +You do not need to stop Synapse to run this command. + +```sh +mas-cli syn2mas check --config mas_config.yaml --synapse-config homeserver.yaml +``` + +This may output a list of errors and warnings. + +If you have any errors, you must resolve them before starting the migration. + +If you have any warnings, please read and understand them, and possibly resolve them. +Resolving warnings is not strictly required before starting the migration. + +### Run the migration in test mode (dry-run) + +MAS can perform a dry-run of the import, which is safe to run without stopping Synapse. +It will perform a full data migration but then empty the MAS database at the end to roll back. + +This means it is safe to run multiple times without worrying about resetting the MAS database. +It also means the time this dry-run takes is representative of the time it will take to perform the actual migration. + +```sh +mas-cli syn2mas migrate --config mas_config.yaml --synapse-config homeserver.yaml --dry-run +``` ## Doing the migration -Having done the preparation, you can now proceed with the actual migration. Note that this will require downtime for the homeserver and is not easily reversible. +Having completed the preparation, you can now proceed with the actual migration. Note that this will require downtime for the homeserver and is not easily reversible. -### Backup your data +### Backup your data and configuration -As with any migration, it is important to backup your data before proceeding. +As with any migration, it is important to back up your data before proceeding. -### Shutdown the homeserver +We also suggest making a backup copy of your homeserver's known good configuration before making any changes to enable MAS integration. -This is to ensure that no new sessions are created whilst the migration is in progress. +### Shut down the homeserver -### Configure the homeserver +This ensures that no new sessions are created while the migration is in progress. + +### Configure the homeserver to enable MAS integration Follow the instructions in the [homeserver configuration guide](homeserver.md) to configure the homeserver to use MAS. ### Do the import -Run `syn2mas` in non-dry-run mode. +Once the homeserver has been stopped, MAS has been configured (but is not running!), and you have a successful migration check, run `syn2mas`'s `migrate` command. ```sh -syn2mas --command migrate --synapseConfigFile homeserver.yaml --masConfigFile config.yaml --dryRun false +mas-cli syn2mas migrate --config mas_config.yaml --synapse-config homeserver.yaml ``` +#### What to do if it goes wrong + +If the migration fails with an error: + +- You can try to fix the error and make another attempt by re-running the command; or +- You can revert your homeserver configuration (so MAS integration is disabled once more) and abort the migration for now. In this case, you should not start MAS up. + +In *some cases*, MAS may have written to its own database during a failed migration, causing it to complain in subsequent runs. +In this case, you can safely delete and recreate the MAS database, then start over. + +In *any case*, the migration tool itself **will not** write to the Synapse database, so as long as MAS hasn't been started, it is safe to roll back the migration without restoring the Synapse database. + +Please report migration failures to the developers. + ### Start up the homeserver Start up the homeserver again with the new configuration. -### Update or serve the .well-known +### Start up MAS -The `.well-known/matrix/client` needs to be served as described [here](./well-known.md). +Now you can start MAS. diff --git a/docs/setup/well-known.md b/docs/setup/well-known.md deleted file mode 100644 index 65b2990b4..000000000 --- a/docs/setup/well-known.md +++ /dev/null @@ -1,23 +0,0 @@ -# .well-known configuration - -A `.well-known/matrix/client` file is required to be served to allow clients to discover the authentication service. - -If no `.well-known/matrix/client` file is served currently then this will need to be enabled. - -If the homeserver is Synapse and serving this file already then the correct values will already be included when the homeserver is [configured to use MAS](./homeserver.md). - -If the .well-known is hosted elsewhere then `org.matrix.msc2965.authentication` entries need to be included similar to the following: - -```json -{ - "m.homeserver": { - "base_url": "https://matrix.example.com" - }, - "org.matrix.msc2965.authentication": { - "issuer": "https://example.com/", - "account": "https://auth.example.com/account" - } -} -``` - -For more context on what the correct values are, see [here](./). diff --git a/docs/topics/admin-api.md b/docs/topics/admin-api.md index 47e9975a6..75d5e2b0a 100644 --- a/docs/topics/admin-api.md +++ b/docs/topics/admin-api.md @@ -67,7 +67,7 @@ clients: # The Swagger UI callback in the hosted documentation - https://element-hq.github.io/matrix-authentication-service/api/oauth2-redirect.html # The Swagger UI callback hosted by the service - - https://mas.example.com/api/doc/oauth2-redirect + - https://mas.example.com/api/doc/oauth2-callback ``` Then, in Swagger UI, click on the "Authorize" button. diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 61129bca2..090812bf0 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -93,6 +93,24 @@ const localazyMetadata: LocalazyMetadata = { localizedName: "Français", pluralType: (n) => { return (n===0 || n===1) ? "one" : "other"; } }, + { + language: "hu", + region: "", + script: "", + isRtl: false, + name: "Hungarian", + localizedName: "Magyar", + pluralType: (n) => { return (n===1) ? "one" : "other"; } + }, + { + language: "nb", + region: "NO", + script: "", + isRtl: false, + name: "Norwegian Bokmål (Norway)", + localizedName: "Norsk bokmål (Norge)", + pluralType: (n) => { return (n===1) ? "one" : "other"; } + }, { language: "nl", region: "", @@ -161,6 +179,8 @@ const localazyMetadata: LocalazyMetadata = { "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", @@ -181,6 +201,8 @@ const localazyMetadata: LocalazyMetadata = { "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", diff --git a/frontend/.storybook/public/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js index a02290705..8b841baf2 100644 --- a/frontend/.storybook/public/mockServiceWorker.js +++ b/frontend/.storybook/public/mockServiceWorker.js @@ -8,7 +8,7 @@ * - Please do NOT serve this file on production. */ -const PACKAGE_VERSION = '2.7.4' +const PACKAGE_VERSION = '2.7.5' const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/frontend/locales/cs.json b/frontend/locales/cs.json index b27ad77d7..65726d2e8 100644 --- a/frontend/locales/cs.json +++ b/frontend/locales/cs.json @@ -300,6 +300,11 @@ "last_auth_label": "Poslední ověření", "name_for_platform": "{{name}} pro {{platform}}", "scopes_label": "Rozsahy", + "set_device_name": { + "help": "Nastavte název, který vám pomůže identifikovat toto zařízení.", + "label": "Název zařízení", + "title": "Upravit název zařízení" + }, "signed_in_date": "Přihlášen ", "signed_in_label": "Přihlášen", "title": "Podrobnosti o zařízení", diff --git a/frontend/locales/da.json b/frontend/locales/da.json index c8df7aa9f..2ed8a4fff 100644 --- a/frontend/locales/da.json +++ b/frontend/locales/da.json @@ -299,6 +299,11 @@ "last_auth_label": "Sidste godkendelse", "name_for_platform": "{{name}} til {{platform}}", "scopes_label": "Omfang", + "set_device_name": { + "help": "Set a name that will help you identify this device.", + "label": "Device name", + "title": "Edit device name" + }, "signed_in_date": "Logget ind ", "signed_in_label": "Logget ind", "title": "Enhedsoplysninger", diff --git a/frontend/locales/de.json b/frontend/locales/de.json index 2a5dba499..6dda0578a 100644 --- a/frontend/locales/de.json +++ b/frontend/locales/de.json @@ -299,6 +299,11 @@ "last_auth_label": "Letzte Anmeldung", "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.", + "label": "Gerätename", + "title": "Gerätename bearbeiten" + }, "signed_in_date": "Angemeldet ", "signed_in_label": "Angemeldet", "title": "Geräte Details", diff --git a/frontend/locales/en.json b/frontend/locales/en.json index bb2fd5a7a..929b1a99c 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -259,6 +259,11 @@ "last_active_label": "Last Active", "name_for_platform": "{{name}} for {{platform}}", "scopes_label": "Scopes", + "set_device_name": { + "help": "Set a name that will help you identify this device.", + "label": "Device name", + "title": "Edit device name" + }, "signed_in_label": "Signed in", "title": "Device details", "unknown_browser": "Unknown browser", diff --git a/frontend/locales/et.json b/frontend/locales/et.json index 563268162..d2a065609 100644 --- a/frontend/locales/et.json +++ b/frontend/locales/et.json @@ -299,6 +299,11 @@ "last_auth_label": "Viimati autenditud", "name_for_platform": "{{name}} / {{platform}}", "scopes_label": "Õigused", + "set_device_name": { + "help": "Sisesta nimi, mis aitab sul hiljem seda seadet ära tunda.", + "label": "Seadme nimi", + "title": "Muuda seadme nime" + }, "signed_in_date": "Sisse logitud ", "signed_in_label": "Sisse logitud", "title": "Seadme andmed", diff --git a/frontend/locales/fi.json b/frontend/locales/fi.json index 784ffa5fc..163c507d1 100644 --- a/frontend/locales/fi.json +++ b/frontend/locales/fi.json @@ -299,6 +299,11 @@ "last_auth_label": "Viimeisin todennus", "name_for_platform": "{{name}} {{platform}}:lle", "scopes_label": "Vaikutusalue", + "set_device_name": { + "help": "Aseta nimi, jonka avulla tunnistat tämän laitteen.", + "label": "Laitteen nimi", + "title": "Muokkaa laitteen nimeä" + }, "signed_in_date": "Kirjautunut sisään ", "signed_in_label": "Kirjautunut sisään", "title": "Laitteen tiedot", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 885e42b35..b71a3795e 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -299,6 +299,11 @@ "last_auth_label": "Dernière authentification", "name_for_platform": "{{name}} pour {{platform}}", "scopes_label": "Scopes", + "set_device_name": { + "help": "Définissez un nom qui vous aidera à identifier cet appareil.", + "label": "Nom de l'appareil", + "title": "Modifier le nom de l'appareil" + }, "signed_in_date": "Connecté ", "signed_in_label": "Connecté", "title": "Détails de la session", diff --git a/frontend/locales/hu.json b/frontend/locales/hu.json new file mode 100644 index 000000000..5c4b9d10d --- /dev/null +++ b/frontend/locales/hu.json @@ -0,0 +1,400 @@ +{ + "action": { + "back": "Vissza", + "cancel": "Mégse", + "clear": "Törlés", + "close": "Bezárás", + "collapse": "Összecsukás", + "confirm": "Megerősítés", + "continue": "Folytatás", + "edit": "Szerkesztés", + "expand": "Kibontás", + "save": "Mentés", + "save_and_continue": "Mentés és folytatás", + "sign_out": "Kijelentkezés", + "start_over": "Újrakezdés" + }, + "branding": { + "privacy_policy": { + "alt": "Hivatkozás a szolgáltatás adatvédelmi irányelveire", + "link": "Adatvédelmi irányelvek" + }, + "terms_and_conditions": { + "alt": "Hivatkozás a szolgáltatási feltételekre", + "link": "Szolgáltatási feltételek" + } + }, + "common": { + "add": "Hozzáadás", + "e2ee": "Végpontok közti titkosítás", + "error": "Hiba", + "loading": "Betöltés…", + "next": "Következő", + "password": "Jelszó", + "previous": "Előző", + "saved": "Mentve", + "saving": "Mentés…" + }, + "frontend": { + "account": { + "account_password": "Fiók jelszava", + "contact_info": "Kapcsolati információ", + "delete_account": { + "alert_description": "Ez a fiók véglegesen törölve lesz, és többé nem fog hozzáférni az üzeneteihez.", + "alert_title": "Hamarosan elveszíti az összes adatát", + "button": "Fiók törlése", + "dialog_description": "Erősítse meg, hogy törölné a fiókját:\n\n\nNem fogja tudni újraaktiválni a fiókját\nTöbbé nem fog tudni bejelentkezni\nSenki sem fogja tudni használni a felhasználónevét (MXID), Önt is beleértve\nElhagyja az összes szobáját és a közvetlen üzeneteit\nEl lesz távolítva az azonosítási kiszolgálóról, és senki sem fogja tudni megtalálni az e-mail-címe vagy telefonszáma alapján\n\nA régi üzenetei továbbra is láthatóak lesznek azok számára, akik megkapták azokat. Elrejti az elküldött üzeneteit azok elől, akik a jövőben csatlakoznak a szobákhoz?", + "dialog_title": "Törli ezt a fiókot?", + "erase_checkbox_label": "Igen, az összes üzenet elrejtése az új érkezők elől", + "incorrect_password": "Helytelen jelszó, próbálja újra", + "mxid_label": "Erősítse meg a Matrix-azonosítóját ({{ mxid }})", + "mxid_mismatch": "Ez az érték nem egyezik a Matrix-azonosítójával", + "password_label": "A folytatáshoz adja meg a jelszavát" + }, + "edit_profile": { + "display_name_help": "Ez az, amit mások látni fognak, ha be van jelentkezve.", + "display_name_label": "Megjelenítési név", + "title": "Profil szerkesztése", + "username_label": "Felhasználói név" + }, + "password": { + "change": "Jelszó módosítása", + "change_disabled": "A jelszóváltoztatást letiltotta a rendszergazda.", + "label": "Jelszó" + }, + "sign_out": { + "button": "Kijelentkezés a fiókból", + "dialog": "Kijelentkezik ebből a fiókból?" + }, + "title": "Saját fiók" + }, + "add_email_form": { + "email_denied_alert": { + "text": "A megadott e-mail-címet nem engedélyezi a kiszolgáló házirendje.", + "title": "E-mail-cím házirend alapján elutasítva" + }, + "email_denied_error": "A megadott e-mail-címet nem engedélyezi a kiszolgáló házirendje", + "email_exists_alert": { + "text": "A megadott e-mail-cím már hozzá lett adva ehhez a fiókhoz", + "title": "Az e-mail-cím már létezik" + }, + "email_exists_error": "A megadott e-mail-cím már hozzá lett adva ehhez a fiókhoz", + "email_field_help": "Alternatív e-mail-cím hozzáadása, mellyel hozzáférhet ehhez a fiókhoz.", + "email_field_label": "E-mail-cím hozzáadása", + "email_in_use_error": "A megadott e-mail-cím már használatban van", + "email_invalid_alert": { + "text": "A megadott e-mail-cím érvénytelen", + "title": "Érvénytelen e-mail-cím" + }, + "email_invalid_error": "A megadott e-mail-cím érvénytelen", + "incorrect_password_error": "Helytelen jelszó, próbálja újra", + "password_confirmation": "Erősítse meg a fiókja jelszavát az e-mail-cím hozzáadásához" + }, + "app_sessions_list": { + "error": "Az alkalmazás munkameneteinek betöltése sikertelen", + "heading": "Alkalmazások" + }, + "browser_session_details": { + "current_badge": "Jelenlegi", + "session_details_title": "Munkamenet" + }, + "browser_sessions_overview": { + "body:one": "{{count}} aktív munkamenet", + "body:other": "{{count}} aktív munkamenet", + "heading": "Böngészők", + "no_active_sessions": { + "default": "Még egyetlen webböngészőben sem jelentkezett be.", + "inactive_90_days": "Az összes munkamenete aktív volt az elmúlt 90 napban." + }, + "view_all_button": "Összes megtekintése" + }, + "compat_session_detail": { + "client_details_title": "Kliensinformációk", + "name": "Név", + "session_details_title": "Munkamenet" + }, + "device_type_icon_label": { + "desktop": "Asztali számítógép", + "mobile": "Mobil", + "pc": "Számítógép", + "tablet": "Táblagép", + "unknown": "Ismeretlen eszköztípus", + "web": "Web" + }, + "email_in_use": { + "heading": "A(z) {{email}} e-mail-cím már használatban van." + }, + "end_session_button": { + "confirmation_modal_title": "Biztos, hogy befejezi a munkamenetet?", + "text": "Eszköz eltávolítása" + }, + "error": { + "hideDetails": "Részletek elrejtése", + "showDetails": "Részletek megjelenítése", + "subtitle": "Váratlan hiba történt. Próbálja újra.", + "title": "Valamilyen hiba történt" + }, + "error_boundary_title": "Valamilyen hiba történt", + "errors": { + "field_required": "Ez a mező kötelező", + "rate_limit_exceeded": "Túl sok kérést adott fel egy rövid időszak alatt. Várjon néhány percet, és próbálja újra." + }, + "last_active": { + "active_date": "{{relativeDate}} aktív", + "active_now": "Jelenleg aktív", + "inactive_90_days": "90+ napja inaktív" + }, + "nav": { + "devices": "Eszközök", + "profile": "Profil", + "sessions": "Munkamenetek", + "settings": "Beállítások" + }, + "not_found_alert_title": "Nem található.", + "not_logged_in_alert": "Nincs bejelentkezve.", + "oauth2_client_detail": { + "details_title": "Kliensinformációk", + "id": "Kliensazonosító", + "name": "Név", + "policy": "Házirend", + "terms": "Szolgáltatás feltételei" + }, + "oauth2_session_detail": { + "client_details_name": "Név", + "client_title": "Kliensinformációk", + "session_details_title": "Munkamenet" + }, + "pagination_controls": { + "total": "Összesen: {{totalCount}}" + }, + "password_change": { + "current_password_label": "Jelenlegi jelszó", + "failure": { + "description": { + "account_locked": "A fiókja zárolva van, és jelenleg nem állítható helyre. Ha erre nem számított, akkor lépjen kapcsolatba a kiszolgáló rendszergazdájával.", + "expired_recovery_ticket": "A helyreállítási hivatkozás lejárt. Kezdje az elejéről a fiókja helyreállítási folyamatát.", + "invalid_new_password": "A választott új jelszó érvénytelen; lehet, hogy nem felel meg a beállított biztonsági házirendnek.", + "no_current_password": "Nincs jelenlegi jelszava.", + "no_such_recovery_ticket": "A helyreállítási hivatkozás érvénytelen. Ha a helyreállítási üzenetből másolta ki a hivatkozást, akkor ellenőrizze, hogy a teljes hivatkozást átmásolta-e.", + "password_changes_disabled": "A jelszómódosítás le van tiltva.", + "recovery_ticket_already_used": "A helyreállítási hivatkozás már fel lett használva. Többé nem használható.", + "unspecified": "Ez ideiglenes probléma is lehet, így próbálja újra később. Ha a probléma továbbra is fennáll, lépjen kapcsolatba a kiszolgáló rendszergazdájával.", + "wrong_password": "A jelenlegi jelszóként megadott jelszó helytelen. Próbálja újra." + }, + "title": "A jelszó frissítése sikertelen" + }, + "new_password_again_label": "Adja meg a jelszót újból", + "new_password_label": "Új jelszó", + "passwords_match": "A jelszavak megegyeznek!", + "passwords_no_match": "A jelszavak nem egyeznek", + "subtitle": "Válasszon egy új jelszót a fiókjához.", + "success": { + "description": "A jelszava sikeresen frissült.", + "title": "Jelszó frissítve" + }, + "title": "Jelszó módosítása" + }, + "password_reset": { + "consumed": { + "subtitle": "Új jelszó létrehozásához kezdje elölről, és válassza a „Elfelejtett jelszó” lehetőséget.", + "title": "A jelszó-visszaállítási hivatkozás már fel lett használva" + }, + "expired": { + "resend_email": "Levél újraküldése", + "subtitle": "Új levél kérése, amely ide lesz elküldve: {{email}}", + "title": "A jelszó-visszaállítási hivatkozás lejárt" + }, + "subtitle": "Válasszon egy új jelszót a fiókjához.", + "title": "Jelszó visszaállítása" + }, + "password_strength": { + "placeholder": "Jelszó erőssége", + "score": { + "0": "Rendkívül gyenge jelszó", + "1": "Nagyon gyenge jelszó", + "2": "Gyenge jelszó", + "3": "Erős jelszó", + "4": "Nagyon erős jelszó" + }, + "suggestion": { + "all_uppercase": "Használjon nagybetűket, de nem mindnél.", + "another_word": "Adjon hozzá néhány kevésbé gyakori szót.", + "associated_years": "Kerülje az Önhöz köthető éveket.", + "capitalization": "Ne csak az első betű legyen nagybetűs.", + "dates": "Kerülje az Önhöz köthető dátumokat és éveket.", + "l33t": "Kerülje a kiszámítható betűhelyettesítéseket, mint az „a” helyetti „@”.", + "longer_keyboard_pattern": "Használjon hosszabb billentyűzetmintát, és többször módosítsa a gépelési irányt.", + "no_need": "Anélkül is hozhat létre erős jelszavakat, hogy szimbólumokat, számokat vagy nagybetűket használna.", + "pwned": "Ha máshol is használja ezt a jelszót, akkor változtassa meg.", + "recent_years": "Kerülje a közelmúltbeli éveket.", + "repeated": "Kerülje az ismétlődő szavakat és karaktereket.", + "reverse_words": "Kerülje a gyakori szavak fordított betűzését.", + "sequences": "Kerülje a gyakori karaktersorozatokat.", + "use_words": "Használjon több szót, de kerülje a gyakori kifejezéseket." + }, + "too_weak": "Ez a jelszó túl gyenge", + "warning": { + "common": "Ez egy gyakran használt jelszó.", + "common_names": "A gyakori nevek és vezetéknevek könnyen kitalálhatóak.", + "dates": "A dátumok könnyen kitalálhatóak.", + "extended_repeat": "Az ismétlődő karaktersorozatok, mint az „abcabcabc” könnyen kitalálhatóak.", + "key_pattern": "A rövid billentyűzetminták könnyen kitalálhatóak.", + "names_by_themselves": "Az egy nevet vagy vezetéknevet tartalmazó jelszók könnyen kitalálhatók.", + "pwned": "A jelszava egy adatvédelmi incidensben kikerült az internetre.", + "recent_years": "A közelmúltbeli évek könnyen kitalálhatóak.", + "sequences": "A gyakori karaktersorozatok, mint az „abc”, könnyen kitalálhatóak.", + "similar_to_common": "Ez hasonlít egy gyakran használt jelszóhoz.", + "simple_repeat": "Az ismétlődő karakterek, mint az „aaa” könnyen kitalálhatóak.", + "straight_row": "A billentyűzeten szereplő karaktersorok könnyen kitalálhatóak.", + "top_hundred": "Ez egy gyakran használt jelszó.", + "top_ten": "Ez egy nagyon gyakran használt jelszó.", + "user_inputs": "Ne legyen benne személyes, vagy az oldallal kapcsolatos adat.", + "word_by_itself": "Az egy szavas jelszavak könnyen kitalálhatóak." + } + }, + "reset_cross_signing": { + "button": "Személyazonosság alaphelyzetbe állítása", + "cancelled": { + "description_1": "A folytatáshoz bezárhatja ezt az ablakot, és visszatérhet az alkalmazáshoz.", + "description_2": "Ha mindenhonnan kijelentkezett, és nem emlékszik a helyreállítási kódjára, akkor alaphelyzetbe kell állítania a személyazonosságát.", + "heading": "Személyazonosság alaphelyzetbe állítása megszakítva." + }, + "description": "Ha nincs bejelentkezve egyetlen más eszközön sem, és elvesztette a helyreállítási kulcsát, akkor az alkalmazás használatának folytatásához alaphelyzetbe kell állítania a személyazonosságát.", + "effect_list": { + "negative_1": "El fogja veszíteni a meglévő üzenetelőzményeit", + "negative_2": "Újból ellenőriznie kell az összes meglévő eszközét és kapcsolatát", + "neutral_1": "El fogja veszíteni a csak a kiszolgálón tárolt üzenetelőzményeit", + "neutral_2": "Újból ellenőriznie kell az összes meglévő eszközét és kapcsolatát", + "positive_1": "A fiókja részletei, a névjegyei, a beállításai és a csevegéslistája meg lesz tartva" + }, + "failure": { + "description": "Ez ideiglenes probléma is lehet, így próbálja újra később. Ha a probléma továbbra is fennáll, lépjen kapcsolatba a kiszolgáló rendszergazdájával.", + "heading": "A kriptográfiai személyazonossága alaphelyzetbe állításának engedélyezése sikertelen", + "title": "A kriptográfiai személyazonosságának engedélyezése sikertelen" + }, + "finish_reset": "Alaphelyzetbe állítás befejezése", + "heading": "Állítsa alaphelyzetbe a személyazonosságát, ha semmilyen más módon nem tudja megerősíteni", + "start_reset": "Alaphelyzetbe állítás elkezdése", + "success": { + "description": "A személyazonosság alaphelyzetbe állítása engedélyezve a következő {{minutes}} percre. A folytatáshoz bezárhatja azt az ablakot, és visszatérhet az alkalmazáshoz.", + "heading": "Személyazonosság sikeresen alaphelyzetbe állítva. A folyamat befejezéséhez térjen vissza az alkalmazáshoz.", + "title": "A kriptográfiai személyazonossága alaphelyzetbe állítása ideiglenesen engedélyezve" + }, + "warning": "Csak akkor állítsa alaphelyzetbe a személyazonosságát, ha nem fér hozzá más bejelentkezett eszközhöz, és elveszítette a helyreállítási kulcsát." + }, + "selectable_session": { + "label": "Munkamenet kiválasztása" + }, + "session": { + "client_id_label": "Kliensazonosító", + "current": "Jelenlegi", + "current_badge": "Jelenlegi", + "device_id_label": "Eszközazonosító", + "finished_date": "Befejezve: ", + "finished_label": "Befejezve", + "generic_browser_session": "Böngésző-munkamenet", + "id_label": "Azonosító", + "ip_label": "IP-cím", + "last_active_label": "Legutóbb aktív", + "last_auth_label": "Legutóbbi hitelesítés", + "name_for_platform": "{{name}} erre: {{platform}}", + "scopes_label": "Hatókörök", + "set_device_name": { + "help": "Adjon meg egy nevet, amely segít az eszköz azonosításában.", + "label": "Eszköz neve", + "title": "Eszköz nevének szerkesztése" + }, + "signed_in_date": "Bejelentkezve: ", + "signed_in_label": "Bejelentkezve", + "title": "Eszköz részletei", + "unknown_browser": "Ismeretlen böngésző", + "unknown_device": "Ismeretlen eszköz", + "uri_label": "URI", + "user_id_label": "Felhasználóazonosító", + "username_label": "Felhasználónév" + }, + "session_detail": { + "alert": { + "button": "Vissza", + "text": "Ez a munkamenet nem létezik, vagy már nem aktív.", + "title": "A munkamenet nem található: {{deviceId}}" + } + }, + "unknown_route": "Ismeretlen útvonal: {{route}}", + "unverified_email_alert": { + "button": "Áttekintés és ellenőrzés", + "text:one": "{{count}} nem ellenőrzött e-mail-címe van.", + "text:other": "{{count}} nem ellenőrzött e-mail-címe van.", + "title": "Nem megerősített e-mail-cím" + }, + "user_email": { + "cant_delete_primary": "Válasszon egy másik elsődleges e-mail-címet, hogy törölhesse ezt.", + "delete_button_confirmation_modal": { + "action": "E-mail-cím törlése", + "body": "Törli ezt az e-mail-címet?", + "incorrect_password": "Helytelen jelszó, próbálja újra", + "password_confirmation": "Erősítse meg a fiókja jelszavát az e-mail-cím törléséhez" + }, + "delete_button_title": "E-mail-cím eltávolítása", + "email": "E-mail", + "make_primary_button": "Elsődlegessé tétel", + "not_verified": "Nincs ellenőrizve", + "primary_email": "Elsődleges e-mail-cím", + "retry_button": "Kód újraküldése", + "unverified": "Ellenőrizetlen" + }, + "user_email_list": { + "heading": "Levelek", + "no_primary_email_alert": "Nincs elsődleges e-mail-cím" + }, + "user_greeting": { + "error": "A felhasználó betöltése sikertelen" + }, + "user_name": { + "display_name_field_label": "Megjelenítési név" + }, + "user_sessions_overview": { + "active_sessions:one": "{{count}} aktív munkamenet", + "active_sessions:other": "{{count}} aktív munkamenet", + "heading": "Hol jelentkezett be", + "no_active_sessions": { + "default": "Egyetlen alkalmazásba sincs bejelentkezve.", + "inactive_90_days": "Az összes munkamenete aktív volt az elmúlt 90 napban." + } + }, + "verify_email": { + "code_expired_alert": { + "description": "A kód lejárt. Kérjen egy újat.", + "title": "A kód lejárt" + }, + "code_field_error": "A kód nem ismerhető fel", + "code_field_label": "6 számjegyű kód", + "code_field_wrong_shape": "A kódnak 6 számjegyűnek kell lennie", + "email_sent_alert": { + "description": "Adja meg alább az új kódot.", + "title": "Új kód küldve" + }, + "enter_code_prompt": "Adja meg az ide küldött 6 számjegyű kódot: {{email}}", + "heading": "E-mail-cím ellenőrzése", + "invalid_code_alert": { + "description": "A folytatáshoz ellenőrizze az e-mail-címére küldött kódot, és frissítse a lenti mezőket.", + "title": "Hibás kódot adott meg" + }, + "resend_code": "Kód újraküldése", + "resend_email": "Levél újraküldése", + "sent": "Elküldve!", + "unknown_email": "Ismeretlen e-mail-cím" + } + }, + "mas": { + "scope": { + "edit_profile": "Profil és elérhetőségek szerkesztése", + "manage_sessions": "Eszközök és munkamenetek kezelése", + "mas_admin": "Bármely felhasználó kezelése a matrix-authentication-service szolgáltatásban", + "send_messages": "Új üzenetek küldése az Ön nevében", + "synapse_admin": "A Synapse Matrix-kiszolgáló kezelése", + "view_messages": "Meglévő üzenetek és adatok megtekintése", + "view_profile": "Saját profilinformációk és kapcsolati részletek megtekintése" + } + } +} \ No newline at end of file diff --git a/frontend/locales/nb-NO.json b/frontend/locales/nb-NO.json new file mode 100644 index 000000000..0e7512ccb --- /dev/null +++ b/frontend/locales/nb-NO.json @@ -0,0 +1,400 @@ +{ + "action": { + "back": "Tilbake", + "cancel": "Avbryt", + "clear": "Tøm", + "close": "Lukk", + "collapse": "Skjul", + "confirm": "Bekreft", + "continue": "Fortsett", + "edit": "Rediger", + "expand": "Utvid", + "save": "Lagre", + "save_and_continue": "Lagre og fortsett", + "sign_out": "Logg ut", + "start_over": "Begynn på nytt" + }, + "branding": { + "privacy_policy": { + "alt": "Lenke til tjenestens personvernerklæring", + "link": "Personvernerklæring" + }, + "terms_and_conditions": { + "alt": "Lenke til tjenestens vilkår og betingelser", + "link": "Vilkår og betingelser" + } + }, + "common": { + "add": "Legg til", + "e2ee": "Ende-til-ende-kryptering", + "error": "Feil", + "loading": "Laster inn...", + "next": "Neste", + "password": "Passord", + "previous": "Forrige", + "saved": "Lagret", + "saving": "Lagrer…" + }, + "frontend": { + "account": { + "account_password": "Passord for konto", + "contact_info": "Kontaktopplysninger", + "delete_account": { + "alert_description": "Denne kontoen vil bli slettet permanent, og du vil ikke lenger ha tilgang til noen av meldingene dine.", + "alert_title": "Du er i ferd med å miste alle dataene dine", + "button": "Slett konto", + "dialog_description": "Bekreft at du ønsker å slette kontoen din:\n\n\nDu vil ikke kunne aktivere kontoen din på nytt\nDu vil ikke lenger kunne logge på\nIngen vil kunne gjenbruke brukernavnet ditt (MXID), heller ikke du\nDu vil forlate alle rom og direktemeldinger du er en del av\nDu vil bli fjernet fra identitetsserveren, og ingen vil kunne finne deg med e-postadressen eller telefonnummeret ditt\n\nDine gamle meldinger vil fortsatt være synlige for personer som har mottatt dem. Ønsker du å skjule dine sendte meldinger for personer som blir med i rommene i fremtiden?", + "dialog_title": "Slett denne kontoen?", + "erase_checkbox_label": "Ja, skjul alle meldingene mine for nye medlemmer", + "incorrect_password": "Feil passord, prøv igjen", + "mxid_label": "Bekreft din Matrix ID ({{ mxid }})", + "mxid_mismatch": "Denne verdien samsvarer ikke med din Matrix ID", + "password_label": "Skriv inn passordet ditt for å fortsette" + }, + "edit_profile": { + "display_name_help": "Dette er det andre vil se uansett hvor du er logget inn.", + "display_name_label": "Visningsnavn", + "title": "Rediger profil", + "username_label": "Brukernavn" + }, + "password": { + "change": "Endre passord", + "change_disabled": "Endring av passord er deaktivert av administrator.", + "label": "Passord" + }, + "sign_out": { + "button": "Logg av konto", + "dialog": "Logg ut av denne kontoen?" + }, + "title": "Din konto" + }, + "add_email_form": { + "email_denied_alert": { + "text": "Den angitte e-postadressen er ikke tillatt av serverpolicyen.", + "title": "E-post avvist av policy" + }, + "email_denied_error": "Den angitte e-postadressen er ikke tillatt av serverpolicyen", + "email_exists_alert": { + "text": "Den angitte e-postadressen er allerede lagt til denne kontoen", + "title": "E-posten finnes allerede" + }, + "email_exists_error": "Den angitte e-postadressen er allerede lagt til denne kontoen", + "email_field_help": "Legg til en alternativ e-postadresse du kan bruke for å få tilgang til denne kontoen.", + "email_field_label": "Legg til e-post", + "email_in_use_error": "Den angitte e-postadressen er allerede i bruk", + "email_invalid_alert": { + "text": "Den angitte e-postadressen er ugyldig", + "title": "Ugyldig e-post" + }, + "email_invalid_error": "Den angitte e-postadressen er ugyldig", + "incorrect_password_error": "Feil passord, prøv igjen", + "password_confirmation": "Bekreft passordet ditt for å legge til denne e-postadressen" + }, + "app_sessions_list": { + "error": "Kunne ikke laste inn appsesjoner", + "heading": "Applikasjoner" + }, + "browser_session_details": { + "current_badge": "Nåværende", + "session_details_title": "Sesjon" + }, + "browser_sessions_overview": { + "body:one": "{{count}} aktiv sesjon", + "body:other": "{{count}} aktive sesjoner", + "heading": "Nettlesere", + "no_active_sessions": { + "default": "Du er ikke logget inn på noen nettlesere.", + "inactive_90_days": "Alle sesjonene dine har vært aktive de siste 90 dagene." + }, + "view_all_button": "Vis alle" + }, + "compat_session_detail": { + "client_details_title": "Klient informasjon", + "name": "Navn", + "session_details_title": "Sesjon" + }, + "device_type_icon_label": { + "desktop": "Skrivebord", + "mobile": "Mobil", + "pc": "Datamaskin", + "tablet": "Nettbrett", + "unknown": "Ukjent enhetstype", + "web": "Web" + }, + "email_in_use": { + "heading": "E-postadressen {{email}} er allerede i bruk." + }, + "end_session_button": { + "confirmation_modal_title": "Er du sikker på at du vil avslutte denne sesjonen?", + "text": "Fjern enheten" + }, + "error": { + "hideDetails": "Skjul detaljer", + "showDetails": "Vis detaljer", + "subtitle": "Det oppstod en uventet feil. Vennligst prøv igjen.", + "title": "Noe gikk galt" + }, + "error_boundary_title": "Noe gikk galt", + "errors": { + "field_required": "Dette feltet er obligatorisk", + "rate_limit_exceeded": "Du har kommet med for mange forespørsler på kort tid. Vent noen minutter og prøv igjen." + }, + "last_active": { + "active_date": "Aktiv {{relativeDate}}", + "active_now": "Aktiv nå", + "inactive_90_days": "Inaktiv i 90+ dager" + }, + "nav": { + "devices": "Enheter", + "profile": "Profil", + "sessions": "Sesjoner", + "settings": "Innstillinger" + }, + "not_found_alert_title": "Ikke funnet.", + "not_logged_in_alert": "Du er ikke innlogget.", + "oauth2_client_detail": { + "details_title": "Klient informasjon", + "id": "Klient-ID", + "name": "Navn", + "policy": "Retningslinjer", + "terms": "Vilkår for bruk" + }, + "oauth2_session_detail": { + "client_details_name": "Navn", + "client_title": "Klient informasjon", + "session_details_title": "Sesjon" + }, + "pagination_controls": { + "total": "Totalt: {{totalCount}}" + }, + "password_change": { + "current_password_label": "Nåværende passord", + "failure": { + "description": { + "account_locked": "Kontoen din er låst og kan ikke gjenopprettes på dette tidspunktet. Hvis dette ikke er forventet, kan du kontakte serveradministratoren din.", + "expired_recovery_ticket": "Gjenopprettingslenken er utløpt. Start kontogjenopprettingsprosessen på nytt.", + "invalid_new_password": "Det nye passordet du valgte er ugyldig. Det kan hende at den ikke oppfyller den gjeldende sikkerhetspolicyen.", + "no_current_password": "Du har ikke et gjeldende passord.", + "no_such_recovery_ticket": "Gjenopprettingslenken er ugyldig. Hvis du kopierte lenken fra gjenopprettingseposten, vennligst sjekk at hele lenken ble kopiert.", + "password_changes_disabled": "Endring av passord er deaktivert.", + "recovery_ticket_already_used": "Gjenopprettingslenken er allerede brukt. Den kan ikke brukes igjen.", + "unspecified": "Dette kan være et midlertidig problem, så prøv igjen senere. Hvis problemet vedvarer, vennligst kontakt serveradministratoren din.", + "wrong_password": "Passordet du oppga som ditt nåværende passord er feil. Prøv igjen." + }, + "title": "Kunne ikke oppdatere passordet" + }, + "new_password_again_label": "Skriv inn nytt passord igjen", + "new_password_label": "Nytt passord", + "passwords_match": "Passordene stemmer overens!", + "passwords_no_match": "Passord stemmer ikke overens", + "subtitle": "Velg et nytt passord for kontoen din.", + "success": { + "description": "Passordet ditt har blitt oppdatert.", + "title": "Passord oppdatert" + }, + "title": "Bytt passordet ditt" + }, + "password_reset": { + "consumed": { + "subtitle": "For å opprette et nytt passord, start på nytt og velg «Glemt passord».", + "title": "Lenken for å tilbakestille passordet ditt har allerede blitt brukt" + }, + "expired": { + "resend_email": "Send e-post på nytt", + "subtitle": "Be om en ny e-post som vil bli sendt til: {{email}}", + "title": "Lenken for å tilbakestille passordet ditt er utløpt" + }, + "subtitle": "Velg et nytt passord for kontoen din.", + "title": "Tilbakestill passordet ditt" + }, + "password_strength": { + "placeholder": "Passordstyrke", + "score": { + "0": "Ekstremt svakt passord", + "1": "Veldig svakt passord", + "2": "Svakt passord", + "3": "Sterkt passord", + "4": "Veldig sterkt passord" + }, + "suggestion": { + "all_uppercase": "Bruk store bokstaver, men ikke for alle bokstaver.", + "another_word": "Legg til flere ord som er mindre vanlige.", + "associated_years": "Unngå år som er knyttet til deg", + "capitalization": "Bruk stor bokstav på mer enn den første bokstaven.", + "dates": "Unngå datoer og år som er knyttet til deg", + "l33t": "Unngå forutsigbare bokstavbytter som \"@\" i stedet for \"a\".", + "longer_keyboard_pattern": "Bruk lengre tastaturmønstre og endre skriveretning flere ganger.", + "no_need": "Du kan lage sterke passord uten å bruke symboler, tall eller store bokstaver.", + "pwned": "Hvis du bruker dette passordet andre steder, bør du endre det.", + "recent_years": "Unngå nylige år", + "repeated": "Unngå gjentatte ord og tegn.", + "reverse_words": "Unngå omvendt staving av vanlige ord.", + "sequences": "Unngå vanlige tegnsekvenser.", + "use_words": "Bruk flere ord, men unngå vanlige fraser." + }, + "too_weak": "Dette passordet er for svakt", + "warning": { + "common": "Dette er et ofte brukt passord.", + "common_names": "Vanlige navn og etternavn er lette å gjette seg til.", + "dates": "Datoer er enkle å gjette seg til.", + "extended_repeat": "Gjentatte tegnmønstre som \"abcabcabc\" er lette å gjette seg til.", + "key_pattern": "Korte tastaturmønstre er enkle å gjette.", + "names_by_themselves": "Enkeltnavn eller etternavn er lette å gjette.", + "pwned": "Passordet ditt ble eksponert ved et datainnbrudd på Internett.", + "recent_years": "De siste årene er enkle å gjette seg til.", + "sequences": "Vanlige tegnsekvenser som «abc» er enkle å gjette.", + "similar_to_common": "Dette ligner på et ofte brukt passord.", + "simple_repeat": "Gjentatte tegn som \"aaa\" er lette å gjette.", + "straight_row": "Rette tasterader på tastaturet er enkle å gjette seg til.", + "top_hundred": "Dette er et ofte brukt passord.", + "top_ten": "Dette er et mye brukt passord.", + "user_inputs": "Det skal ikke være noen personlige eller siderelaterte data.", + "word_by_itself": "Enkeltord er lette å gjette." + } + }, + "reset_cross_signing": { + "button": "Tilbakestill identitet", + "cancelled": { + "description_1": "Du kan lukke dette vinduet og gå tilbake til appen for å fortsette.", + "description_2": "Hvis du er logget av overalt og ikke husker gjenopprettingskoden, må du fortsatt tilbakestille identiteten din.", + "heading": "Tilbakestilling av identitet kansellert." + }, + "description": "Hvis du ikke er logget på andre enheter, og du har mistet gjenopprettingsnøkkelen, må du tilbakestille identiteten din for å fortsette å bruke appen.", + "effect_list": { + "negative_1": "Du vil miste din eksisterende meldingshistorikk", + "negative_2": "Du må bekrefte alle eksisterende enheter og kontakter på nytt", + "neutral_1": "Du vil miste all meldingshistorikk som bare er lagret på serveren", + "neutral_2": "Du må bekrefte alle eksisterende enheter og kontakter på nytt", + "positive_1": "Dine kontodetaljer, kontakter, preferanser og chatteliste vil bli beholdt" + }, + "failure": { + "description": "Dette kan være et midlertidig problem, så prøv igjen senere. Hvis problemet vedvarer, vennligst kontakt serveradministratoren din.", + "heading": "Kunne ikke tillate tilbakestilling av kryptoidentitet", + "title": "Kunne ikke tillate kryptoidentitet" + }, + "finish_reset": "Fullfør tilbakestillingen", + "heading": "Tilbakestill identiteten din i tilfelle du ikke kan bekrefte på en annen måte", + "start_reset": "Start tilbakestilling", + "success": { + "description": "Tilbakestillingen av identiteten er godkjent for de neste {{minutes}} minuttene. Du kan lukke dette vinduet og gå tilbake til appen for å fortsette.", + "heading": "Identitet tilbakestilt. Gå tilbake til appen for å fullføre prosessen.", + "title": "Tilbakestilling av kryptoidentitet midlertidig tillatt" + }, + "warning": "Tilbakestill identiteten din bare hvis du ikke har tilgang til en annen pålogget enhet og du har mistet gjenopprettingsnøkkelen." + }, + "selectable_session": { + "label": "Velg sesjon" + }, + "session": { + "client_id_label": "Klient-ID", + "current": "Nåværende", + "current_badge": "Nåværende", + "device_id_label": "Enhets-ID", + "finished_date": "Fullført ", + "finished_label": "Fullført", + "generic_browser_session": "Nettlesersesjon", + "id_label": "ID", + "ip_label": "IP-adresse", + "last_active_label": "Sist aktiv", + "last_auth_label": "Siste autentisering", + "name_for_platform": "{{name}} for {{platform}}", + "scopes_label": "Omfang", + "set_device_name": { + "help": "Angi et navn som hjelper deg med å identifisere denne enheten.", + "label": "Navn på enhet", + "title": "Rediger navnet på enheten" + }, + "signed_in_date": "Logget på ", + "signed_in_label": "Logget på", + "title": "Detaljer om enheten", + "unknown_browser": "Ukjent nettleser", + "unknown_device": "Ukjent enhet", + "uri_label": "Uri", + "user_id_label": "Bruker ID", + "username_label": "Brukernavn" + }, + "session_detail": { + "alert": { + "button": "Gå tilbake", + "text": "Denne sesjonen finnes ikke, eller er ikke lenger aktiv.", + "title": "Finner ikke sesjonen: {{deviceId}}" + } + }, + "unknown_route": "Ukjent rute {{route}}", + "unverified_email_alert": { + "button": "Gjennomgå og verifiser", + "text:one": "Du har {{count}} ubekreftet e-postadresse.", + "text:other": "Du har {{count}} ubekreftede e-postadresser.", + "title": "Ubekreftet e-post" + }, + "user_email": { + "cant_delete_primary": "Velg en annen primær e-postadresse for å slette denne.", + "delete_button_confirmation_modal": { + "action": "Slett e-post", + "body": "Vil du slette denne e-posten?", + "incorrect_password": "Feil passord, prøv igjen", + "password_confirmation": "Bekreft kontopassordet ditt for å slette denne e-postadressen" + }, + "delete_button_title": "Fjern e-postadresse", + "email": "E-post", + "make_primary_button": "Gjøre til primær", + "not_verified": "Ikke verifisert", + "primary_email": "Primær e-postadresse", + "retry_button": "Send kode på nytt", + "unverified": "Ikke verifisert" + }, + "user_email_list": { + "heading": "E-poster", + "no_primary_email_alert": "Ingen primær e-postadresse" + }, + "user_greeting": { + "error": "Kunne ikke laste inn bruker" + }, + "user_name": { + "display_name_field_label": "Visningsnavn" + }, + "user_sessions_overview": { + "active_sessions:one": "{{count}} aktiv sesjon", + "active_sessions:other": "{{count}} aktive sesjoner", + "heading": "Hvor du er logget inn", + "no_active_sessions": { + "default": "Du er ikke logget på noen applikasjoner.", + "inactive_90_days": "Alle sesjonene dine har vært aktive de siste 90 dagene." + } + }, + "verify_email": { + "code_expired_alert": { + "description": "Koden er utløpt. Be om en ny kode.", + "title": "Koden er utløpt" + }, + "code_field_error": "Kode ikke gjenkjent", + "code_field_label": "6-sifret kode", + "code_field_wrong_shape": "Koden må være 6 sifre", + "email_sent_alert": { + "description": "Skriv inn den nye koden nedenfor.", + "title": "Ny kode sendt" + }, + "enter_code_prompt": "Skriv inn den 6-sifrede koden sendt til: {{email}}", + "heading": "Bekreft e-postadressen din", + "invalid_code_alert": { + "description": "Sjekk koden som er sendt til e-posten din, og oppdater feltene nedenfor for å fortsette.", + "title": "Du skrev inn feil kode" + }, + "resend_code": "Send kode på nytt", + "resend_email": "Send e-post på nytt", + "sent": "Sendt!", + "unknown_email": "Ukjent e-postadresse" + } + }, + "mas": { + "scope": { + "edit_profile": "Rediger din profil og kontaktdetaljer", + "manage_sessions": "Administrer enhetene og sesjonene dine", + "mas_admin": "Administrer alle brukere på matrix-authentication-service", + "send_messages": "Send nye meldinger på dine vegne", + "synapse_admin": "Administrer Synapse-hjemmeserveren", + "view_messages": "Se dine eksisterende meldinger og data", + "view_profile": "Se din profilinformasjon og kontaktdetaljer" + } + } +} \ No newline at end of file diff --git a/frontend/locales/nl.json b/frontend/locales/nl.json index 96470b016..244beb2f0 100644 --- a/frontend/locales/nl.json +++ b/frontend/locales/nl.json @@ -299,6 +299,11 @@ "last_auth_label": "Laatste authenticatie", "name_for_platform": "{{name}} voor {{platform}}", "scopes_label": "Scopes", + "set_device_name": { + "help": "Set a name that will help you identify this device.", + "label": "Device name", + "title": "Edit device name" + }, "signed_in_date": "Ingelogd op ", "signed_in_label": "Ingelogd", "title": "Device details", diff --git a/frontend/locales/pt.json b/frontend/locales/pt.json index c91b8b227..7542ade69 100644 --- a/frontend/locales/pt.json +++ b/frontend/locales/pt.json @@ -9,9 +9,9 @@ "continue": "Continuar", "edit": "Editar", "expand": "Expandir", - "save": "Salvar", + "save": "Guardar", "save_and_continue": "Guardar e Continuar", - "sign_out": "Sair", + "sign_out": "Terminar sessão", "start_over": "Recomeçar" }, "branding": { @@ -40,10 +40,10 @@ "account_password": "Palavra-passe da conta", "contact_info": "Informações de contacto", "delete_account": { - "alert_description": "Esta conta será permanentemente apagada e deixará de ter acesso a todas as suas mensagens.", + "alert_description": "Esta conta será permanentemente eliminada e deixará de ter acesso a todas as suas mensagens.", "alert_title": "Está prestes a perder todos os seus dados", "button": "Eliminar conta", - "dialog_description": "Confirme que pretende apagar a sua conta:\n\n\nNão poderá reativar a sua conta\nDeixará de poder iniciar sessão\nNinguém poderá reutilizar o seu nome de utilizador (MXID), incluindo você\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_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", @@ -52,19 +52,19 @@ "password_label": "Introduza a sua palavra-passe para continuar" }, "edit_profile": { - "display_name_help": "Isso é o que os outros verão onde quer que você esteja conectado.", - "display_name_label": "Nome para exibição", + "display_name_help": "Isto é o que os outros verão sempre que tiver sessão iniciada.", + "display_name_label": "Nome de exibição", "title": "Editar perfil", "username_label": "Nome de utilizador" }, "password": { "change": "Alterar palavra-passe", - "change_disabled": "As alterações de palavra-passe são desactivadas pelo administrador.", - "label": "Palavra-Passe" + "change_disabled": "As alterações de palavra-passe são desativadas pelo administrador.", + "label": "Palavra-passe" }, "sign_out": { - "button": "Sair da conta", - "dialog": "Sair desta conta?" + "button": "Terminar sessão na conta", + "dialog": "Terminar sessão nesta conta?" }, "title": "A sua conta" }, @@ -79,7 +79,7 @@ "title": "O e-mail já existe" }, "email_exists_error": "O e-mail introduzido já foi adicionado a esta conta", - "email_field_help": "Adicione um e-mail alternativo que você pode usar para acessar essa conta.", + "email_field_help": "Adicione um e-mail alternativo que possa utilizar para aceder a esta conta.", "email_field_label": "Adicionar e-mail", "email_in_use_error": "O e-mail introduzido já está a ser utilizado", "email_invalid_alert": { @@ -87,11 +87,11 @@ "title": "E-mail inválido" }, "email_invalid_error": "O e-mail introduzido é inválido", - "incorrect_password_error": "Palavra-passe incorrecta, tente novamente", - "password_confirmation": "Confirme a palavra-passe da sua conta para adicionar este endereço de correio eletrónico" + "incorrect_password_error": "Palavra-passe incorreta, tente novamente", + "password_confirmation": "Confirme a palavra-passe da sua conta para adicionar este endereço de e-mail" }, "app_sessions_list": { - "error": "Falha ao carregar sessões do aplicativo", + "error": "Falha ao carregar sessões da aplicação", "heading": "Aplicações" }, "browser_session_details": { @@ -103,7 +103,7 @@ "body:other": "{{count}} sessões ativas", "heading": "Navegadores", "no_active_sessions": { - "default": "Você não está conectado a nenhum navegador da Web.", + "default": "Não tem sessão iniciada em nenhum navegador Web.", "inactive_90_days": "Todas as suas sessões estiveram ativas nos últimos 90 dias." }, "view_all_button": "Ver tudo" @@ -137,7 +137,7 @@ "error_boundary_title": "Algo correu mal", "errors": { "field_required": "Este campo é obrigatório", - "rate_limit_exceeded": "Você fez muitas solicitações em um curto período. Aguarde alguns minutos e tente novamente." + "rate_limit_exceeded": "Efetuou demasiadas solicitações num curto espaço de tempo. Aguarde alguns minutos e tente novamente." }, "last_active": { "active_date": "Ativo {{relativeDate}}", @@ -299,6 +299,11 @@ "last_auth_label": "Última autenticação", "name_for_platform": "{{name}}para{{platform}}", "scopes_label": "Âmbitos de aplicação", + "set_device_name": { + "help": "Defina um nome que o ajude a identificar este dispositivo.", + "label": "Nome do dispositivo", + "title": "Editar nome do dispositivo" + }, "signed_in_date": "Conectado ", "signed_in_label": "Sessão iniciada", "title": "Detalhes do dispositivo", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 9778cad04..a1d019238 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -300,6 +300,11 @@ "last_auth_label": "Последняя аутентификация", "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" + }, "signed_in_date": "Вошёл ", "signed_in_label": "Вошёл в систему", "title": "Сведения об устройстве", diff --git a/frontend/locales/sv.json b/frontend/locales/sv.json index 3ec4968a1..45d52aab6 100644 --- a/frontend/locales/sv.json +++ b/frontend/locales/sv.json @@ -299,6 +299,11 @@ "last_auth_label": "Senaste autentisering", "name_for_platform": "{{name}} för {{platform}}", "scopes_label": "Scope", + "set_device_name": { + "help": "Set a name that will help you identify this device.", + "label": "Device name", + "title": "Edit device name" + }, "signed_in_date": "Inloggad ", "signed_in_label": "Inloggad", "title": "Enhetsdetaljer", diff --git a/frontend/locales/uk.json b/frontend/locales/uk.json index 26399a9ec..4df4d3c42 100644 --- a/frontend/locales/uk.json +++ b/frontend/locales/uk.json @@ -300,6 +300,11 @@ "last_auth_label": "Остання автентифікація", "name_for_platform": "{{name}} для {{platform}}", "scopes_label": "Області застосування (Scopes)", + "set_device_name": { + "help": "Вкажіть назву, яка допоможе вам ідентифікувати цей пристрій.", + "label": "Назва пристрою", + "title": "Змінити назву пристрою" + }, "signed_in_date": "Вхід виконано ", "signed_in_label": "Вхід виконано", "title": "Деталі пристрою", diff --git a/frontend/locales/zh-Hans.json b/frontend/locales/zh-Hans.json index ba9478815..b052bf585 100644 --- a/frontend/locales/zh-Hans.json +++ b/frontend/locales/zh-Hans.json @@ -298,6 +298,11 @@ "last_auth_label": "最后认证", "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" + }, "signed_in_date": "已登录", "signed_in_label": "已登录", "title": "设备详情", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78a4b18e9..f20d7d906 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,21 +10,21 @@ "dependencies": { "@fontsource/inconsolata": "^5.2.5", "@fontsource/inter": "^5.2.5", - "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", - "@tanstack/react-query": "^5.74.3", - "@tanstack/react-router": "^1.116.0", - "@vector-im/compound-design-tokens": "4.0.1", - "@vector-im/compound-web": "^7.10.1", + "@radix-ui/react-collapsible": "^1.1.8", + "@radix-ui/react-dialog": "^1.1.11", + "@tanstack/react-query": "^5.75.5", + "@tanstack/react-router": "^1.120.2", + "@vector-im/compound-design-tokens": "4.0.2", + "@vector-im/compound-web": "^7.10.2", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "i18next": "^24.2.3", + "i18next": "^25.1.2", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-i18next": "^15.4.1", - "swagger-ui-dist": "^5.20.7", + "react-i18next": "^15.5.1", + "swagger-ui-dist": "^5.21.0", "valibot": "^1.0.0", "vaul": "^1.1.2" }, @@ -34,47 +34,47 @@ "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", "@codecov/vite-plugin": "^1.9.0", - "@graphql-codegen/cli": "^5.0.5", + "@graphql-codegen/cli": "^5.0.6", "@graphql-codegen/client-preset": "^4.8.0", "@graphql-codegen/typescript-msw": "^3.0.0", "@storybook/addon-essentials": "^8.6.12", "@storybook/addon-interactions": "^8.6.12", "@storybook/react": "^8.6.12", "@storybook/react-vite": "^8.6.12", - "@storybook/test": "^8.5.5", - "@tanstack/react-query-devtools": "^5.74.3", - "@tanstack/react-router-devtools": "^1.116.0", - "@tanstack/router-plugin": "^1.116.1", + "@storybook/test": "^8.6.12", + "@tanstack/react-query-devtools": "^5.75.5", + "@tanstack/react-router-devtools": "^1.120.2", + "@tanstack/router-plugin": "^1.120.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.14.1", - "@types/react": "19.1.1", - "@types/react-dom": "19.1.2", + "@types/node": "^22.15.16", + "@types/react": "19.1.3", + "@types/react-dom": "19.1.3", "@types/swagger-ui-dist": "^3.30.5", - "@vitejs/plugin-react": "^4.4.0", - "@vitest/coverage-v8": "^3.1.1", + "@vitejs/plugin-react": "^4.4.1", + "@vitest/coverage-v8": "^3.1.3", "autoprefixer": "^10.4.21", "browserslist-to-esbuild": "^2.1.1", - "graphql": "^16.10.0", - "happy-dom": "^17.4.4", + "graphql": "^16.11.0", + "happy-dom": "^17.4.6", "i18next-parser": "^9.3.0", - "knip": "^5.50.2", - "msw": "^2.7.4", + "knip": "^5.54.1", + "msw": "^2.7.5", "msw-storybook-addon": "^2.0.4", "postcss": "^8.5.3", "postcss-import": "^16.1.0", "postcss-nesting": "^13.0.1", "rimraf": "^6.0.1", - "storybook": "^8.5.5", - "storybook-react-i18next": "^3.2.1", + "storybook": "^8.6.12", + "storybook-react-i18next": "^3.3.1", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", - "vite": "6.3.0", + "vite": "6.3.5", "vite-plugin-compression": "^0.5.1", - "vite-plugin-graphql-codegen": "^3.5.0", + "vite-plugin-graphql-codegen": "^3.6.1", "vite-plugin-manifest-sri": "^0.2.0", - "vitest": "^3.0.5" + "vitest": "^3.1.2" } }, "node_modules/@actions/core": { @@ -285,13 +285,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", + "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.5", + "@babel/compat-data": "^7.26.8", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -312,18 +312,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz", + "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/traverse": "^7.27.0", "semver": "^6.3.1" }, "engines": { @@ -666,13 +666,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", - "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz", + "integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -753,13 +753,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", - "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { @@ -1003,9 +1003,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -1367,13 +1367,29 @@ } }, "node_modules/@envelop/core": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.0.3.tgz", - "integrity": "sha512-SE3JxL7odst8igN6x77QWyPpXKXz/Hs5o5Y27r+9Br6WHIhkW90lYYVITWIJQ/qYgn5PkpbaVgeFY9rgqQaZ/A==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.2.3.tgz", + "integrity": "sha512-KfoGlYD/XXQSc3BkM1/k15+JQbkQ4ateHazeZoWl9P71FsLTDXSjGy6j7QqfhpIDSbxNISqhPMfZHYSbDFOofQ==", "dev": true, "license": "MIT", "dependencies": { - "@envelop/types": "5.0.0", + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" }, "engines": { @@ -1381,12 +1397,13 @@ } }, "node_modules/@envelop/types": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.0.0.tgz", - "integrity": "sha512-IPjmgSc4KpQRlO4qbEDnBEixvtb06WDmjKfi/7fkZaryh5HuOmTtixe1EupQI5XfXO8joc3d27uUZ0QdC++euA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", "dev": true, "license": "MIT", "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.5.0" }, "engines": { @@ -1394,9 +1411,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "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==", "cpu": [ "ppc64" ], @@ -1411,9 +1428,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", "cpu": [ "arm" ], @@ -1428,9 +1445,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", "cpu": [ "arm64" ], @@ -1445,9 +1462,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "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==", "cpu": [ "x64" ], @@ -1462,9 +1479,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", "cpu": [ "arm64" ], @@ -1479,9 +1496,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", "cpu": [ "x64" ], @@ -1496,9 +1513,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "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==", "cpu": [ "arm64" ], @@ -1513,9 +1530,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", "cpu": [ "x64" ], @@ -1530,9 +1547,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", "cpu": [ "arm" ], @@ -1547,9 +1564,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", "cpu": [ "arm64" ], @@ -1564,9 +1581,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", "cpu": [ "ia32" ], @@ -1581,9 +1598,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "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==", "cpu": [ "loong64" ], @@ -1598,9 +1615,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", "cpu": [ "mips64el" ], @@ -1615,9 +1632,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", "cpu": [ "ppc64" ], @@ -1632,9 +1649,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "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==", "cpu": [ "riscv64" ], @@ -1649,9 +1666,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "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==", "cpu": [ "s390x" ], @@ -1666,9 +1683,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", "cpu": [ "x64" ], @@ -1683,9 +1700,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", "cpu": [ "arm64" ], @@ -1700,9 +1717,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "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==", "cpu": [ "x64" ], @@ -1717,9 +1734,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "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==", "cpu": [ "arm64" ], @@ -1734,9 +1751,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", "cpu": [ "x64" ], @@ -1751,9 +1768,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", "cpu": [ "x64" ], @@ -1768,9 +1785,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", "cpu": [ "arm64" ], @@ -1785,9 +1802,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", "cpu": [ "ia32" ], @@ -1802,9 +1819,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "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==", "cpu": [ "x64" ], @@ -1819,14 +1836,11 @@ } }, "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz", + "integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } + "license": "MIT" }, "node_modules/@floating-ui/core": { "version": "1.6.9", @@ -1848,9 +1862,9 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.4.tgz", - "integrity": "sha512-05mXdkUiVh8NCEcYKQ2C9SV9IkZ9k/dFtYmaEIN2riLv80UHoXylgBM76cgPJYfLJM3dJz7UE5MOVH0FypMd2Q==", + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.8.tgz", + "integrity": "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.2", @@ -1921,16 +1935,16 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/cli": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.5.tgz", - "integrity": "sha512-9p9SI5dPhJdyU+O6p1LUqi5ajDwpm6pUhutb1fBONd0GZltLFwkgWFiFtM6smxkYXlYVzw61p1kTtwqsuXO16w==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.6.tgz", + "integrity": "sha512-1r5dtZ2l1jiCF/4qLMTcT7mEoWWWeqQlmn7HcPHgnV/OXIEodwox7XRGAmOKUygoabRjFF3S0jd0TWbkq5Otsw==", "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.6.0", + "@graphql-codegen/client-preset": "^4.8.1", "@graphql-codegen/core": "^4.0.2", "@graphql-codegen/plugin-helpers": "^5.0.3", "@graphql-tools/apollo-engine-loader": "^8.0.0", @@ -1939,7 +1953,7 @@ "@graphql-tools/github-loader": "^8.0.0", "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", - "@graphql-tools/load": "^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", @@ -1983,9 +1997,9 @@ } }, "node_modules/@graphql-codegen/client-preset": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.0.tgz", - "integrity": "sha512-IVtTl7GsPMbQihk5+l5fDYksnPPOoC52sKxzquyIyuecZLEB7W3nNLV29r6+y+tjXTRPA774FR7CHGA2adzhjw==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.1.tgz", + "integrity": "sha512-XLF2V7WKLnepvrGE44JP+AvjS+Oz9AT0oYgTl/6d9btQ+2VYFcmwQPjNAuMVHipqE9I6h8hSEfH9hUrzUptB1g==", "dev": true, "license": "MIT", "dependencies": { @@ -1996,7 +2010,7 @@ "@graphql-codegen/plugin-helpers": "^5.1.0", "@graphql-codegen/typed-document-node": "^5.1.1", "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/typescript-operations": "^4.6.0", + "@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/visitor-plugin-common": "^5.8.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", @@ -2009,6 +2023,11 @@ "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", "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } } }, "node_modules/@graphql-codegen/client-preset/node_modules/tslib": { @@ -2530,9 +2549,9 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.0.tgz", - "integrity": "sha512-/EltSdE/uPoEAblRTVLABVDhsrE//Kl3pCflyG1PWl4gWL9/OzQXYGjo6TF6bPMVn/QBWoO0FeboWf+bk84SXA==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.1.tgz", + "integrity": "sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2548,6 +2567,11 @@ "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", "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } } }, "node_modules/@graphql-codegen/typescript-operations/node_modules/tslib": { @@ -2596,14 +2620,24 @@ "dev": true, "license": "0BSD" }, + "node_modules/@graphql-hive/signal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-1.0.0.tgz", + "integrity": "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@graphql-tools/apollo-engine-loader": { - "version": "8.0.15", - "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.15.tgz", - "integrity": "sha512-4Y3gmTrC9nK8Zb19VSvPGecncUV7nFnRg9CpsdsSvjS2N98wmUhFwH9jCYQzLyDKgvlJV5PEHhAeVQPQgKGFeg==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.8.6", "@whatwg-node/fetch": "^0.10.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" @@ -2616,13 +2650,14 @@ } }, "node_modules/@graphql-tools/batch-execute": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.11.tgz", - "integrity": "sha512-v9b618cj3hIrRGTDrOotYzpK+ZigvNcKdXK3LNBM4g/uA7pND0d4GOnuOSBQGKKN6kT/1nsz4ZpUxCoUvWPbzg==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.7.0", + "@graphql-tools/utils": "^10.8.1", + "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, @@ -2634,14 +2669,14 @@ } }, "node_modules/@graphql-tools/code-file-loader": { - "version": "8.1.15", - "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.15.tgz", - "integrity": "sha512-XlrzWfuoBRfpx/5Uw8VBP5rmMJyQVv8HMd6k/7TxFT/cXU34rcQfmRk6f3J7gD5+3ueqgwPcmaIn3CRp+Z0r0w==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.14", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/graphql-tag-pluck": "8.3.19", + "@graphql-tools/utils": "^10.8.6", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -2654,17 +2689,18 @@ } }, "node_modules/@graphql-tools/delegate": { - "version": "10.2.11", - "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.11.tgz", - "integrity": "sha512-eLqczQkDlSHpz0foBWfjISSsHiedMOBz4spaa1ako1eM4bX9VxQa/HWQuMK8dmAf8By+F47OzvLUNa03Aq6vXw==", + "version": "10.2.17", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.17.tgz", + "integrity": "sha512-z+LpZrTQCEXA4fbdJcSsvhaMqT4xi/O8B0mP30ENGyTbSfa20QamOQx9jgCiw2ii/ucwxfGMhygwlpZG36EU4w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/batch-execute": "^9.0.11", - "@graphql-tools/executor": "^1.3.10", + "@graphql-tools/batch-execute": "^9.0.15", + "@graphql-tools/executor": "^1.4.7", "@graphql-tools/schema": "^10.0.11", - "@graphql-tools/utils": "^10.7.0", + "@graphql-tools/utils": "^10.8.1", "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "dset": "^3.1.2", "tslib": "^2.8.1" @@ -2694,18 +2730,18 @@ } }, "node_modules/@graphql-tools/executor": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.3.14.tgz", - "integrity": "sha512-tDk8bYIgbVmGNh7cYewi5/yNCq6UoVl9ugDU4rF//+E1R5TxkXNe9nu6AonE+j6XkA/z+FofVawOqCCiJhJ40g==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.8.6", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.5", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" }, "engines": { "node": ">=16.0.0" @@ -2715,14 +2751,14 @@ } }, "node_modules/@graphql-tools/executor-common": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.1.tgz", - "integrity": "sha512-Gan7uiQhKvAAl0UM20Oy/n5NGBBDNm+ASHvnYuD8mP+dAH0qY+2QMCHyi5py28WAlhAwr0+CAemEyzY/ZzOjdQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.4.tgz", + "integrity": "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q==", "dev": true, "license": "MIT", "dependencies": { - "@envelop/core": "^5.0.2", - "@graphql-tools/utils": "^10.7.0" + "@envelop/core": "^5.2.3", + "@graphql-tools/utils": "^10.8.1" }, "engines": { "node": ">=18.0.0" @@ -2732,15 +2768,15 @@ } }, "node_modules/@graphql-tools/executor-graphql-ws": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.1.tgz", - "integrity": "sha512-TqdNLuDuLgaB8xOe+H4+3Sx9jyhRMpEwJTA+5MOvrMRPIPYW4P7w4KYouClS1zAmn4193pNRSt11lZptjIoLPw==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/executor-common": "^0.0.1", - "@graphql-tools/utils": "^10.7.0", - "@whatwg-node/disposablestack": "^0.0.5", + "@graphql-tools/executor-common": "^0.0.4", + "@graphql-tools/utils": "^10.8.1", + "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.3", "isomorphic-ws": "^5.0.0", "tslib": "^2.8.1", @@ -2754,21 +2790,21 @@ } }, "node_modules/@graphql-tools/executor-http": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.2.6.tgz", - "integrity": "sha512-D+gcyDlqKFiwVEubf9HxNijG7LAck0ApXypv/oBzBIOt7avy+PkNopcjSikxy0BvxkIfyjaAtnYlmgf4kC6Y8g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.3.3.tgz", + "integrity": "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/executor-common": "^0.0.1", - "@graphql-tools/utils": "^10.7.0", + "@graphql-hive/signal": "^1.0.0", + "@graphql-tools/executor-common": "^0.0.4", + "@graphql-tools/utils": "^10.8.1", "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.5", - "@whatwg-node/fetch": "^0.10.1", - "extract-files": "^11.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.4", + "@whatwg-node/promise-helpers": "^1.3.0", "meros": "^1.2.1", - "tslib": "^2.8.1", - "value-or-promise": "^1.0.12" + "tslib": "^2.8.1" }, "engines": { "node": ">=18.0.0" @@ -2778,13 +2814,13 @@ } }, "node_modules/@graphql-tools/executor-legacy-ws": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.12.tgz", - "integrity": "sha512-thZTsx4rGbekMdJxpv0r4ettUsGRpkhSx1z86bn/WEAItn2GjPL/lR508OtP8o/BHFGrQOEIURhwtSpetdINGA==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.8.6", "@types/ws": "^8.0.0", "isomorphic-ws": "^5.0.0", "tslib": "^2.4.0", @@ -2798,14 +2834,14 @@ } }, "node_modules/@graphql-tools/git-loader": { - "version": "8.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.19.tgz", - "integrity": "sha512-jOJ4memazxOmPK+rebhQ99fShKibGr+WSkbsMdwWzU149fuQ7nSj4opNRkhYvDsB6ZYw3wriSlPzSlpaBTLMJQ==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.14", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/graphql-tag-pluck": "8.3.19", + "@graphql-tools/utils": "^10.8.6", "is-glob": "4.0.3", "micromatch": "^4.0.8", "tslib": "^2.4.0", @@ -2819,16 +2855,17 @@ } }, "node_modules/@graphql-tools/github-loader": { - "version": "8.0.15", - "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.15.tgz", - "integrity": "sha512-XPrkc8YotQybbyJ6kiCNlpyCIFzsmmhwnSoqMaZrgL5RRsKbRD4CR8KTmfvMGzZmvt+u4n/te4x1QSZPLnvLqA==", + "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==", "dev": true, "license": "MIT", "dependencies": { "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/graphql-tag-pluck": "^8.3.14", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/graphql-tag-pluck": "^8.3.19", + "@graphql-tools/utils": "^10.8.6", "@whatwg-node/fetch": "^0.10.0", + "@whatwg-node/promise-helpers": "^1.0.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" }, @@ -2840,14 +2877,14 @@ } }, "node_modules/@graphql-tools/graphql-file-loader": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.0.14.tgz", - "integrity": "sha512-Mlcd8u1u6WMRgvvERKfFRL0txTLKtmbmq0x8DzIZ7BACrYCv2rwtV79J51LbFUNBO6cMzu8rzoxTneqYm6dRNg==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/import": "7.0.13", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/import": "7.0.18", + "@graphql-tools/utils": "^10.8.6", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -2860,18 +2897,18 @@ } }, "node_modules/@graphql-tools/graphql-tag-pluck": { - "version": "8.3.14", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.14.tgz", - "integrity": "sha512-dRo5f5/VwLI8bHRfgxl0q11fGFB/K+0/8Z8goPRQOT/Olik1RYnHVPhnK5BGSTLAMVpE3E7F+5jntkXLmuHuRA==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.22.9", - "@babel/parser": "^7.16.8", - "@babel/plugin-syntax-import-assertions": "^7.20.0", - "@babel/traverse": "^7.16.8", - "@babel/types": "^7.16.8", - "@graphql-tools/utils": "^10.8.1", + "@babel/core": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "@graphql-tools/utils": "^10.8.6", "tslib": "^2.4.0" }, "engines": { @@ -2882,13 +2919,13 @@ } }, "node_modules/@graphql-tools/import": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.0.13.tgz", - "integrity": "sha512-END2Bg0bvLnXDHi8WUbD7xrnf8komlIkKMOzSexFLeGpEYPlMsBOM6m0RW31Zk8zdN01gLPAyyT4tQXSIzCGIw==", + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.0.18.tgz", + "integrity": "sha512-1tw1/1QLB0n5bPWfIrhCRnrHIlbMvbwuifDc98g4FPhJ7OXD+iUQe+IpmD5KHVwYWXWhZOuJuq45DfV/WLNq3A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.8.6", "resolve-from": "5.0.0", "tslib": "^2.4.0" }, @@ -2900,13 +2937,13 @@ } }, "node_modules/@graphql-tools/json-file-loader": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.13.tgz", - "integrity": "sha512-T8s05fcWvwkB9iM77RQ8WBGylkzZQ+aFzYZabg51jXvusiXWLCN3BSKPsEvSPpb3Y7JJBAK4e+Hu7UmZxqolkA==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.8.6", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -2919,14 +2956,14 @@ } }, "node_modules/@graphql-tools/load": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.0.14.tgz", - "integrity": "sha512-ECdc/hoSs455B6ksO25mEK/FWDPQqWXQ5aMUgjqaApiPasipOfdsZEINHc8ikm/T+hF++/h6+9PDJNsIideWuQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.0.tgz", + "integrity": "sha512-OGfOm09VyXdNGJS/rLqZ6ztCiG2g6AMxhwtET8GZXTbnjptFc17GtKwJ3Jv5w7mjJ8dn0BHydvIuEKEUK4ciYw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/schema": "^10.0.18", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/schema": "^10.0.23", + "@graphql-tools/utils": "^10.8.6", "p-limit": "3.1.0", "tslib": "^2.4.0" }, @@ -2938,13 +2975,13 @@ } }, "node_modules/@graphql-tools/merge": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.0.19.tgz", - "integrity": "sha512-iJP3Xke+vgnST58A1Q/1+y3bzfbYalIMnegUNupYHNvHHSE0PXoq8YieqQF8JYzWVACMxiq/M4Y1vW75mS2UVg==", + "version": "9.0.24", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.0.24.tgz", + "integrity": "sha512-NzWx/Afl/1qHT3Nm1bghGG2l4jub28AdvtG11PoUlmjcIjnFBJMv4vqL0qnxWe8A82peWo4/TkVdjJRLXwgGEw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/utils": "^10.8.6", "tslib": "^2.4.0" }, "engines": { @@ -3020,14 +3057,14 @@ } }, "node_modules/@graphql-tools/schema": { - "version": "10.0.18", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.18.tgz", - "integrity": "sha512-6j2O/07v1zbGvASizMSO7YZdGt/9HfPDx8s9n75sD2xoGfeJ2aRSmI4LkyuvqOpi0ecaa9xErnMEEvUaKBqMbw==", + "version": "10.0.23", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.23.tgz", + "integrity": "sha512-aEGVpd1PCuGEwqTXCStpEkmheTHNdMayiIKH1xDWqYp9i8yKv9FRDgkGrY4RD8TNxnf7iII+6KOBGaJ3ygH95A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.0.19", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/merge": "^9.0.24", + "@graphql-tools/utils": "^10.8.6", "tslib": "^2.4.0" }, "engines": { @@ -3038,19 +3075,20 @@ } }, "node_modules/@graphql-tools/url-loader": { - "version": "8.0.26", - "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.26.tgz", - "integrity": "sha512-oX8WWpiHHhLvxYUoo0QVN0Jjn2x2Tx9EvfccH+r7Mmgr4QpDU+t5Kpzr7qCRt9kO1SNW1ns1MeiXVWXPjoT6MQ==", + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.31.tgz", + "integrity": "sha512-QGP3py6DAdKERHO5D38Oi+6j+v0O3rkBbnLpyOo87rmIRbwE6sOkL5JeHegHs7EEJ279fBX6lMt8ry0wBMGtyA==", "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.12", - "@graphql-tools/utils": "^10.8.1", + "@graphql-tools/executor-legacy-ws": "^1.1.17", + "@graphql-tools/utils": "^10.8.6", "@graphql-tools/wrap": "^10.0.16", "@types/ws": "^8.0.0", "@whatwg-node/fetch": "^0.10.0", + "@whatwg-node/promise-helpers": "^1.0.0", "isomorphic-ws": "^5.0.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0", @@ -3084,15 +3122,16 @@ } }, "node_modules/@graphql-tools/wrap": { - "version": "10.0.29", - "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.0.29.tgz", - "integrity": "sha512-kQdosPBo6EvFhQV5s0XpN6+N0YN+31mCZTV7uwZisaUwwroAT19ujs2Zxz8Zyw4H9XRCsueLT0wqmSupjIFibQ==", + "version": "10.0.35", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.0.35.tgz", + "integrity": "sha512-qBga3wo7+GqY+ClGexiyRz9xgy1RWozZryTuGX8usGWPa4wKi/tJS4rKWQQesgB3Fh//SZUCRA5u2nwZaZQw1Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/delegate": "^10.2.11", + "@graphql-tools/delegate": "^10.2.17", "@graphql-tools/schema": "^10.0.11", - "@graphql-tools/utils": "^10.7.0", + "@graphql-tools/utils": "^10.8.1", + "@whatwg-node/promise-helpers": "^1.3.0", "tslib": "^2.8.1" }, "engines": { @@ -3126,14 +3165,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.5.tgz", - "integrity": "sha512-ZB2Cz8KeMINUvoeDi7IrvghaVkYT2RB0Zb31EaLWOE87u276w4wnApv0SH2qWaJ3r0VSUa3BIuz7qAV2ZvsZlg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", + "integrity": "sha512-NgQCnHqFTjF7Ys2fsqK2WtnA8X1kHyInyG+nMIuHowVTIgIuS10T4AznI/PvbqSpJqjCUqNBlKGh1v3bwLFL4w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.6", - "@inquirer/type": "^3.0.4" + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6" }, "engines": { "node": ">=18" @@ -3148,14 +3187,14 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.6.tgz", - "integrity": "sha512-Bwh/Zk6URrHwZnSSzAZAKH7YgGYi0xICIBDFOqBQoXNNAzBHw/bgXgLmChfp+GyR3PnChcTbiCTZGC6YJNJkMA==", + "version": "10.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.10.tgz", + "integrity": "sha512-roDaKeY1PYY0aCqhRmXihrHjoSW2A00pV3Ke5fTpMCkzcGF64R8e0lw3dK+eLEHwS4vB5RnW1wuQmvzoRul8Mw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.10", - "@inquirer/type": "^3.0.4", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.6", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", @@ -3196,9 +3235,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", - "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", + "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", "dev": true, "license": "MIT", "engines": { @@ -3206,9 +3245,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", - "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", + "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", "dev": true, "license": "MIT", "engines": { @@ -3508,16 +3547,16 @@ } }, "node_modules/@octokit/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", - "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", + "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.3.1", - "@octokit/request-error": "^5.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" @@ -3527,9 +3566,9 @@ } }, "node_modules/@octokit/endpoint": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.5.tgz", - "integrity": "sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==", + "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": { @@ -3541,13 +3580,13 @@ } }, "node_modules/@octokit/graphql": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.0.tgz", - "integrity": "sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==", + "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.3.0", + "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" }, @@ -3556,16 +3595,16 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "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.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.1.tgz", - "integrity": "sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==", + "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": { @@ -3629,14 +3668,14 @@ } }, "node_modules/@octokit/request": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", - "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "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.1", - "@octokit/request-error": "^5.1.0", + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" }, @@ -3645,9 +3684,9 @@ } }, "node_modules/@octokit/request-error": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", - "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "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": { @@ -3660,13 +3699,13 @@ } }, "node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "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": "^23.0.1" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@open-draft/deferred-promise": { @@ -3706,18 +3745,18 @@ } }, "node_modules/@radix-ui/primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", - "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "license": "MIT" }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", - "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz", + "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2" + "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", @@ -3735,19 +3774,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", - "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.8.tgz", + "integrity": "sha512-hxEsLvK9WxIAPyxdDRULL4hcaSjMZCfP7fHB0Z1uUnDoDBat1Zh46hwYfa69DeZAbJrPckjf0AGAtEZyvDyJbw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/primitive": "1.1.2", + "@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-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3765,15 +3804,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", - "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2" + "@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" }, "peerDependencies": { "@types/react": "*", @@ -3791,9 +3830,9 @@ } }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3806,9 +3845,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3821,17 +3860,17 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", - "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-menu": "2.1.6", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.12", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3849,23 +3888,23 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", - "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/primitive": "1.1.2", + "@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-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-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -3885,9 +3924,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3900,16 +3939,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", - "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3927,18 +3966,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", - "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-menu": "2.1.6", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-controllable-state": "1.1.0" + "@radix-ui/primitive": "1.1.2", + "@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-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3956,9 +3995,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", - "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "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==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3971,14 +4010,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", - "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3996,17 +4035,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.2.tgz", - "integrity": "sha512-Owj1MjLq6/Rp85bgzYI+zRK5APLiWDtXDM63Z39FW15bNdehrcS+FjQgLGQYswFzipYu4GAA+t5w/VqvvNZ3ag==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-label": "2.1.2", - "@radix-ui/react-primitive": "2.0.2" + "@radix-ui/primitive": "1.1.2", + "@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" }, "peerDependencies": { "@types/react": "*", @@ -4024,12 +4063,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4042,12 +4081,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", - "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz", + "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2" + "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", @@ -4065,27 +4104,27 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", - "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-collection": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.2", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-roving-focus": "1.1.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@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-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-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -4105,21 +4144,21 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", - "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "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==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" + "@radix-ui/react-arrow": "1.1.4", + "@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-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4137,13 +4176,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", - "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz", + "integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4161,13 +4200,13 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", - "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4185,12 +4224,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", - "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.1.2" + "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", @@ -4208,13 +4247,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", - "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz", + "integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.2" + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", @@ -4232,20 +4271,20 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", - "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-collection": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@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-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4263,12 +4302,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", - "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", + "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==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2" + "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", @@ -4286,12 +4325,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4304,9 +4343,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4319,12 +4358,31 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4337,12 +4395,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4355,9 +4413,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4370,12 +4428,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.0" + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4388,12 +4446,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4406,9 +4464,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, "node_modules/@repeaterjs/repeater": { @@ -4442,9 +4500,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", - "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", + "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==", "cpu": [ "arm" ], @@ -4456,9 +4514,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", - "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", + "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", "cpu": [ "arm64" ], @@ -4470,9 +4528,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", - "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", + "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==", "cpu": [ "arm64" ], @@ -4484,9 +4542,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", - "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", + "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==", "cpu": [ "x64" ], @@ -4498,9 +4556,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", - "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", + "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", "cpu": [ "arm64" ], @@ -4512,9 +4570,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", - "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", + "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==", "cpu": [ "x64" ], @@ -4526,9 +4584,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", - "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", + "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==", "cpu": [ "arm" ], @@ -4540,9 +4598,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", - "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", + "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==", "cpu": [ "arm" ], @@ -4554,9 +4612,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", - "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", + "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==", "cpu": [ "arm64" ], @@ -4568,9 +4626,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", - "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", + "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==", "cpu": [ "arm64" ], @@ -4582,9 +4640,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", - "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", + "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==", "cpu": [ "loong64" ], @@ -4596,9 +4654,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", - "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "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==", "cpu": [ "ppc64" ], @@ -4610,9 +4668,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", - "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "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==", "cpu": [ "riscv64" ], @@ -4624,9 +4682,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", - "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "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==", "cpu": [ "riscv64" ], @@ -4638,9 +4696,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", - "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "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==", "cpu": [ "s390x" ], @@ -4652,9 +4710,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", - "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "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==", "cpu": [ "x64" ], @@ -4666,9 +4724,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", - "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "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==", "cpu": [ "x64" ], @@ -4680,9 +4738,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", - "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "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==", "cpu": [ "arm64" ], @@ -4694,9 +4752,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", - "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "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==", "cpu": [ "ia32" ], @@ -4708,9 +4766,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", - "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", + "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==", "cpu": [ "x64" ], @@ -5316,9 +5374,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.74.3", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.3.tgz", - "integrity": "sha512-Mqk+5o3qTuAiZML248XpNH8r2cOzl15+LTbUsZQEwvSvn1GU4VQhvqzAbil36p+MBxpr/58oBSnRzhrBevDhfg==", + "version": "5.75.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.5.tgz", + "integrity": "sha512-kPDOxtoMn2Ycycb76Givx2fi+2pzo98F9ifHL/NFiahEDpDwSVW6o12PRuQ0lQnBOunhRG5etatAhQij91M3MQ==", "license": "MIT", "funding": { "type": "github", @@ -5326,9 +5384,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.73.3", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.73.3.tgz", - "integrity": "sha512-hBQyYwsOuO7QOprK75NzfrWs/EQYjgFA0yykmcvsV62q0t6Ua97CU3sYgjHx0ZvxkXSOMkY24VRJ5uv9f5Ik4w==", + "version": "5.74.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.74.7.tgz", + "integrity": "sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw==", "dev": true, "license": "MIT", "funding": { @@ -5337,12 +5395,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.74.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.3.tgz", - "integrity": "sha512-QrycUn0wxjVPzITvQvOxFRdhlAwIoOQSuav7qWD4SWCoKCdLbyRZ2vji2GuBq/glaxbF4wBx3fqcYRDOt8KDTA==", + "version": "5.75.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.5.tgz", + "integrity": "sha512-QrLCJe40BgBVlWdAdf2ZEVJ0cISOuEy/HKupId1aTKU6gPJZVhSvZpH+Si7csRflCJphzlQ77Yx6gUxGW9o0XQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.74.3" + "@tanstack/query-core": "5.75.5" }, "funding": { "type": "github", @@ -5353,32 +5411,32 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.74.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.74.3.tgz", - "integrity": "sha512-H7TsOBB1fRCuuawrBzKMoIszqqILr2IN5oGLYMl7QG7ERJpMdc4hH8OwzBhVxJnmKeGwgtTQgcdKepfoJCWvFg==", + "version": "5.75.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.75.5.tgz", + "integrity": "sha512-S31U00nJOQIbxydRH1kOwdLRaLBrda8O5QjzmgkRg60UZzPGdbI6+873Qa0YGUfPeILDbR2ukgWyg7CJQPy4iA==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.73.3" + "@tanstack/query-devtools": "5.74.7" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.74.3", + "@tanstack/react-query": "^5.75.5", "react": "^18 || ^19" } }, "node_modules/@tanstack/react-router": { - "version": "1.116.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.116.0.tgz", - "integrity": "sha512-ZBAg5Q6zJf0mnP9DYPiaaQ/wLDH2ujCMi/2RllpH86VUkdkyvQQzpAyKoiYJ891wh9OPgj6W6tPrzB4qy5FpRA==", + "version": "1.120.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.120.2.tgz", + "integrity": "sha512-CNduh/O3miW6A/WDMd2cfca8D8x+kVJTYwG5fMaBfcEF/bfjneDnEWXsmKLMdB2iLc6miaRQu66ryPSMdIBUAw==", "license": "MIT", "dependencies": { "@tanstack/history": "1.115.0", "@tanstack/react-store": "^0.7.0", - "@tanstack/router-core": "1.115.3", + "@tanstack/router-core": "1.119.0", "jsesc": "^3.1.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" @@ -5396,13 +5454,13 @@ } }, "node_modules/@tanstack/react-router-devtools": { - "version": "1.116.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.116.0.tgz", - "integrity": "sha512-PsJZWPjcmwZGe71kUvH4bI1ozkv1FgBuBEE0hTYlTCSJ3uG+qv3ndGEI+AiFyuF5OStrbfg0otW1OxeNq5vdGQ==", + "version": "1.120.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.120.2.tgz", + "integrity": "sha512-89qY5pKdIN6r5G0pHP92mLvorzd7rUlHjvCkjNYOyYduxGQ8a703Y7Fp/RqXibwhjvZcet6BR2IrEhIqg9Yjhw==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/router-devtools-core": "^1.115.3", + "@tanstack/router-devtools-core": "^1.119.0", "solid-js": "^1.9.5" }, "engines": { @@ -5413,7 +5471,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.116.0", + "@tanstack/react-router": "^1.120.2", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } @@ -5437,9 +5495,9 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.115.3", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.115.3.tgz", - "integrity": "sha512-gynHs72LHVg05fuJTwZZYhDL4VNEAK0sXz7IqiBv7a3qsYeEmIZsGaFr9sVjTkuF1kbrFBdJd5JYutzBh9Uuhw==", + "version": "1.119.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.119.0.tgz", + "integrity": "sha512-3dZYP5cCq3jJYgnRDzKR3w4sYzrXP5sw1st303ye87VV26r31I8UaIuUEs7kiJaxgWBvqHglWCiygBWQODZXVw==", "license": "MIT", "dependencies": { "@tanstack/history": "1.115.0", @@ -5455,9 +5513,9 @@ } }, "node_modules/@tanstack/router-devtools-core": { - "version": "1.115.3", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.115.3.tgz", - "integrity": "sha512-VBdgw1qxeOD/6FlZ9gitrWPUKGW83CuAW31gf32E0dxL7sIXP+yEFyPlNsVlENan1oSaEuV8tjKkuq5s4MfaPw==", + "version": "1.119.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.119.0.tgz", + "integrity": "sha512-CH2Hx4J2UOigFtKR0anQfNiWQfidV2S7AZafkeo/S885IxwoFK7xXWzYxNbUhCDJC2tsBJ+XKjgxeBv5wGi62Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5472,7 +5530,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/router-core": "^1.115.3", + "@tanstack/router-core": "^1.119.0", "csstype": "^3.0.10", "solid-js": ">=1.9.5", "tiny-invariant": "^1.3.3" @@ -5484,9 +5542,9 @@ } }, "node_modules/@tanstack/router-generator": { - "version": "1.116.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.116.0.tgz", - "integrity": "sha512-XhCp85zP87G2bpSXnosiP3fiMo8HMQD2mvWqFFTFKz87WocabQYGlfhmNYWmBwI50EuS7Ph9lwXsSkV0oKh0xw==", + "version": "1.120.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.120.2.tgz", + "integrity": "sha512-rI+hQjUtsAZs5K2292zM6OE/fHAVRZxejAkrLlaQlunphqJYtHBizXk15SP9QsP3i+QvS1D8YnioMPvSlVPEOw==", "dev": true, "license": "MIT", "dependencies": { @@ -5503,7 +5561,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.116.0" + "@tanstack/react-router": "^1.120.2" }, "peerDependenciesMeta": { "@tanstack/react-router": { @@ -5512,9 +5570,9 @@ } }, "node_modules/@tanstack/router-plugin": { - "version": "1.116.1", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.116.1.tgz", - "integrity": "sha512-9A8DAyRejTzvkVOzgVPUY6l2aH7xOMEXSJJtV9GNbi4NtE6AXUCoFe3mtvYnHSzRqAUMCO0wnfVENCjXQoQYZw==", + "version": "1.120.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.120.2.tgz", + "integrity": "sha512-LVwvd/QKFrxtsKfm1Oiv6+NzAB79hcuhlbu14NwmRfdexPYmfjvJJPK0E3IlLmh/mRlEHmfYajutwuqvBONM9w==", "dev": true, "license": "MIT", "dependencies": { @@ -5524,8 +5582,8 @@ "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", - "@tanstack/router-core": "^1.115.3", - "@tanstack/router-generator": "^1.116.0", + "@tanstack/router-core": "^1.119.0", + "@tanstack/router-generator": "^1.120.2", "@tanstack/router-utils": "^1.115.0", "@tanstack/virtual-file-routes": "^1.115.0", "@types/babel__core": "^7.20.5", @@ -5545,7 +5603,7 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", - "@tanstack/react-router": "^1.116.0", + "@tanstack/react-router": "^1.120.2", "vite": ">=5.0.0 || >=6.0.0", "vite-plugin-solid": "^2.11.2", "webpack": ">=5.92.0" @@ -5569,13 +5627,14 @@ } }, "node_modules/@tanstack/router-plugin/node_modules/unplugin": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.2.0.tgz", - "integrity": "sha512-m1ekpSwuOT5hxkJeZGRxO7gXbXT3gF26NjQ7GdVHoLoF8/nopLcd/QfPigpCy7i51oFHiRJg/CyHhj4vs2+KGw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.2.tgz", + "integrity": "sha512-3n7YA46rROb3zSj8fFxtxC/PqoyvYQ0llwz9wtUPUutr9ig09C8gGo5CWCwHrUzlqC1LLR43kxp5vEIyH1ac1w==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.14.1", + "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" }, "engines": { @@ -5752,9 +5811,9 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -5773,9 +5832,9 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", "dev": true, "license": "MIT", "dependencies": { @@ -5825,9 +5884,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "version": "22.15.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.16.tgz", + "integrity": "sha512-3pr+KjwpVujqWqOKT8mNR+rd09FqhBLwg+5L/4t0cNYBzm/yEiYGCxWttjaPBsLtAo+WFNoXzGJfolM1JuRXoA==", "dev": true, "license": "MIT", "dependencies": { @@ -5835,9 +5894,9 @@ } }, "node_modules/@types/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", - "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", + "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5845,9 +5904,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.1.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", - "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", + "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -5897,9 +5956,9 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", - "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { @@ -5907,9 +5966,9 @@ } }, "node_modules/@vector-im/compound-design-tokens": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.1.tgz", - "integrity": "sha512-V4AsK1FVFxZ6DmmCoeAi8FyvE7ODMlXPWjqRGotcnVaoGNrDQrVz2ZGV85DCz5ISxB3iynYASe6OXsDVXT1zFA==", + "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==", "license": "SEE LICENSE IN README.md", "peerDependencies": { "@types/react": "*", @@ -5925,9 +5984,9 @@ } }, "node_modules/@vector-im/compound-web": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@vector-im/compound-web/-/compound-web-7.10.1.tgz", - "integrity": "sha512-3tVIPCNxXCrMz6TqJc5GiOndPC7bjCRdYIcSKIb7T3B0gVo81aAD2wWL5xSb33yDbXc/tdlKCiav57eQB8dRsQ==", + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@vector-im/compound-web/-/compound-web-7.10.2.tgz", + "integrity": "sha512-K9gA1Ah9CTJMeZTkcDFpAdVRNbu/rQEgV3PoDcEPI3e9iDds8Dhbo7EfOciPvtXCZw6Hr83lnhWDnwTFHVlahQ==", "license": "SEE LICENSE IN README.md", "dependencies": { "@floating-ui/react": "^0.27.0", @@ -5954,9 +6013,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.0.tgz", - "integrity": "sha512-x/EztcTKVj+TDeANY1WjNeYsvZjZdfWRMP/KXi5Yn8BoTzpa13ZltaQqKfvWYbX8CE10GOHHdC5v86jY9x8i/g==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", "dev": true, "license": "MIT", "dependencies": { @@ -5974,9 +6033,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", - "integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.3.tgz", + "integrity": "sha512-cj76U5gXCl3g88KSnf80kof6+6w+K4BjOflCl7t6yRJPDuCrHtVu0SgNYOUARJOL5TI8RScDbm5x4s1/P9bvpw==", "dev": true, "license": "MIT", "dependencies": { @@ -5989,7 +6048,7 @@ "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -5997,8 +6056,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.1", - "vitest": "3.1.1" + "@vitest/browser": "3.1.3", + "vitest": "3.1.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6072,13 +6131,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.3.tgz", + "integrity": "sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6099,9 +6158,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz", + "integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6145,13 +6204,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.3.tgz", + "integrity": "sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.1.3", "pathe": "^2.0.3" }, "funding": { @@ -6159,9 +6218,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz", + "integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==", "dev": true, "license": "MIT", "dependencies": { @@ -6172,13 +6231,13 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.3", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -6187,13 +6246,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.3.tgz", + "integrity": "sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.3", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6202,9 +6261,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz", + "integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==", "dev": true, "license": "MIT", "dependencies": { @@ -6253,12 +6312,13 @@ } }, "node_modules/@whatwg-node/disposablestack": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.5.tgz", - "integrity": "sha512-9lXugdknoIequO4OYvIjhygvfSEgnO8oASLqLelnDhkRjgBZhc39shC3QSlZuyDO9bgYSIVa2cHAiN+St3ty4w==", + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", "dev": true, "license": "MIT", "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" }, "engines": { @@ -6266,13 +6326,13 @@ } }, "node_modules/@whatwg-node/fetch": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", - "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.6.tgz", + "integrity": "sha512-6uzhO2aQ757p3bSHcemA8C4pqEXuyBqyGAM7cYpO0c6/igRMV9As9XL0W12h5EPYMclgr7FgjmbVQBoWEdJ/yA==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/node-fetch": "^0.7.7", + "@whatwg-node/node-fetch": "^0.7.18", "urlpattern-polyfill": "^10.0.0" }, "engines": { @@ -6280,14 +6340,15 @@ } }, "node_modules/@whatwg-node/node-fetch": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.8.tgz", - "integrity": "sha512-Pbv72nbu3AgL9ZaAwdzYcqoMhYGhSBxo49CC+Nt+tlhdDuMZXcf3+41qGghsGJykkxhgfgFcPLwtt2HPEjk57w==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/disposablestack": "^0.0.5", - "busboy": "^1.6.0", + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.1", "tslib": "^2.6.3" }, "engines": { @@ -6295,9 +6356,9 @@ } }, "node_modules/@whatwg-node/promise-helpers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.0.tgz", - "integrity": "sha512-486CouizxHXucj8Ky153DDragfkMcHtVEToF5Pn/fInhUUSiCmt9Q4JVBa6UK5q4RammFBtGQ4C9qhGlXU9YbA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.1.tgz", + "integrity": "sha512-D+OwTEunoQhVHVToD80dPhfz9xgPLqJyEA3F5jCRM14A2u8tBBQVdZekqfqx6ZAfZ+POT4Hb0dn601UKMsvADw==", "dev": true, "license": "MIT", "dependencies": { @@ -6323,9 +6384,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", "bin": { @@ -6375,19 +6436,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/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/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -7030,18 +7078,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -7144,9 +7180,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001703", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz", - "integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==", + "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==", "dev": true, "funding": [ { @@ -7312,9 +7348,9 @@ } }, "node_modules/cheerio/node_modules/undici": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "version": "6.21.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", + "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", "dev": true, "license": "MIT", "engines": { @@ -8001,9 +8037,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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": { @@ -8045,23 +8081,10 @@ "dev": true, "license": "MIT" }, - "node_modules/easy-table": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.2.0.tgz", - "integrity": "sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "optionalDependencies": { - "wcwidth": "^1.0.1" - } - }, "node_modules/electron-to-chromium": { - "version": "1.5.98", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.98.tgz", - "integrity": "sha512-bI/LbtRBxU2GzK7KK5xxFd2y9Lf9XguHooPYbcXWy6wUoT8NMnffsvRhPmSeUHLSDKAEtKuTaEtK4Ms15zkIEA==", + "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==", "dev": true, "license": "ISC" }, @@ -8158,9 +8181,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -8178,9 +8201,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8191,31 +8214,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@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" } }, "node_modules/esbuild-register": { @@ -8283,9 +8306,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", - "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8320,19 +8343,6 @@ "node": ">=0.10.0" } }, - "node_modules/extract-files": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-11.0.0.tgz", - "integrity": "sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20 || >= 14.13" - }, - "funding": { - "url": "https://github.com/sponsors/jaydenseric" - } - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -8367,9 +8377,9 @@ } }, "node_modules/fastq": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", - "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -8409,10 +8419,20 @@ "dev": true, "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^3.0.1" + } + }, "node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -8511,13 +8531,13 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -8527,6 +8547,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formatly": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.2.3.tgz", + "integrity": "sha512-WH01vbXEjh9L3bqn5V620xUAWs32CmK4IzWRRY6ep5zpa/mrisL4d9+pRVuETORVDTQw8OycSO1WC68PL51RaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^1.2.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -8891,9 +8927,9 @@ "license": "ISC" }, "node_modules/graphql": { - "version": "16.10.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.10.0.tgz", - "integrity": "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==", + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", "dev": true, "license": "MIT", "engines": { @@ -8901,15 +8937,15 @@ } }, "node_modules/graphql-config": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.3.tgz", - "integrity": "sha512-RBhejsPjrNSuwtckRlilWzLVt2j8itl74W9Gke1KejDTz7oaA5kVd6wRn9zK9TS5mcmIYGxf7zN7a1ORMdxp1Q==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.5.tgz", + "integrity": "sha512-mG2LL1HccpU8qg5ajLROgdsBzx/o2M6kgI3uAmoaXiSH9PCUbtIyLomLqUtCFaAeG2YCFsl0M5cfQ9rKmDoMVA==", "dev": true, "license": "MIT", "dependencies": { "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", - "@graphql-tools/load": "^8.0.0", + "@graphql-tools/load": "^8.1.0", "@graphql-tools/merge": "^9.0.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", @@ -8956,21 +8992,6 @@ "graphql": "14 - 16" } }, - "node_modules/graphql-sock": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graphql-sock/-/graphql-sock-1.0.1.tgz", - "integrity": "sha512-gSA0CXdNMvNlpEnH2GY1//SUY7laDsAn51sDm4yh6TTH5UkfbNINydyUAoMHHkAaCaOLNXELQmu3GVcSOw4twg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "semantic-to-nullable": "dist/cli/to-nullable.js", - "semantic-to-strict": "dist/cli/to-strict.js" - }, - "peerDependencies": { - "graphql": "15.x || 16.x || 17.x" - } - }, "node_modules/graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", @@ -8988,9 +9009,9 @@ } }, "node_modules/graphql-ws": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.3.tgz", - "integrity": "sha512-mvLRHihMg0llF74vo16063HufZHMGaiMxAjzyj0ARYueIikGzj1khlbPNl7vUc2h9rxbq9pGpQYbqypgq1fAXA==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.4.tgz", + "integrity": "sha512-8b4OZtNOvv8+NZva8HXamrc0y1jluYC0+13gdh7198FKjVzXyTvVc95DCwGzaKEfn3YuWZxUqjJlHe3qKM/F2g==", "dev": true, "license": "MIT", "engines": { @@ -9025,9 +9046,9 @@ } }, "node_modules/happy-dom": { - "version": "17.4.4", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.4.tgz", - "integrity": "sha512-/Pb0ctk3HTZ5xEL3BZ0hK1AqDSAUuRQitOmROPHhfUYEWpmTImwfD8vFDGADmMAX0JYgbcgxWoLFKtsWhcpuVA==", + "version": "17.4.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.6.tgz", + "integrity": "sha512-OEV1hDe9i2rFr66+WZNiwy1S8rAJy6bRXmXql68YJDjdfHBRbN76om+qVh68vQACf6y5Bcr90e/oK53RQxsDdg==", "dev": true, "license": "MIT", "dependencies": { @@ -9231,9 +9252,9 @@ } }, "node_modules/i18next": { - "version": "24.2.3", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", - "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "version": "25.1.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.1.2.tgz", + "integrity": "sha512-SP63m8LzdjkrAjruH7SCI3ndPSgjt4/wX7ouUUOzCW/eY+HzlIo19IQSfYA9X3qRiRP1SYtaTsg/Oz/PGsfD8w==", "funding": [ { "type": "individual", @@ -9262,9 +9283,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.2.tgz", - "integrity": "sha512-shBvPmnIyZeD2VU5jVGIOWP7u9qNG3Lj7mpaiPFpbJ3LVfHZJvVzKR4v1Cb91wAOFpNw442N+LGPzHOHsten2g==", + "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, @@ -9328,6 +9349,38 @@ "yarn": ">=1" } }, + "node_modules/i18next-parser/node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -9918,9 +9971,9 @@ } }, "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "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": { @@ -10017,9 +10070,9 @@ } }, "node_modules/knip": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.50.2.tgz", - "integrity": "sha512-TGpfeeSMlaRd5wUkcb4HsVGSiQrE289LZF9qtW2TLHkAZbB2rM53wVQbXSf1KjOvJfBSZYSyYQ6q79lufrwsPw==", + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.54.1.tgz", + "integrity": "sha512-5zrTw8ou8VO7xXl87CtI5csvmvKdlXX21+jKeicXt3OtPX19mqU2k5dyidUD+Vg3Tlmuf9IWHPGMgcwqjlIjpg==", "dev": true, "funding": [ { @@ -10038,15 +10091,14 @@ "license": "ISC", "dependencies": { "@nodelib/fs.walk": "^1.2.3", - "easy-table": "1.2.0", "enhanced-resolve": "^5.18.1", "fast-glob": "^3.3.3", + "formatly": "^0.2.3", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "minimist": "^1.2.8", "picocolors": "^1.1.0", "picomatch": "^4.0.1", - "pretty-ms": "^9.0.0", "smol-toml": "^1.3.1", "strip-json-comments": "5.0.1", "zod": "^3.22.4", @@ -10549,9 +10601,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.4.tgz", - "integrity": "sha512-A2kuMopOjAjNEYkn0AnB1uj+x7oBjLIunFk7Ud4icEnVWFf6iBekn8oXW4zIwcpfEdWP9sLqyVaHVzneWoGEww==", + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.5.tgz", + "integrity": "sha512-00MyTlY3TJutBa5kiU+jWiz2z5pNJDYHn2TgPkGkh92kMmNH43RqvMXd8y/7HxNn8RjzUbvZWYZjcS36fdb6sw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10607,9 +10659,9 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.34.1.tgz", - "integrity": "sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==", + "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==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -10639,9 +10691,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -10672,6 +10724,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "dev": true, "funding": [ { @@ -11004,27 +11057,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -11057,6 +11097,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "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==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", @@ -11235,9 +11288,9 @@ } }, "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", "engines": { @@ -11460,9 +11513,9 @@ "license": "MIT" }, "node_modules/prettier": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.0.tgz", - "integrity": "sha512-quyMrVt6svPS7CjQ9gKb3GLEX/rl3BCL2oa/QkNcXv4YNVBC9olt3s+H7ukto06q7B1Qz46PbrKLO34PR6vXcA==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", "bin": { @@ -11503,22 +11556,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-ms": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", - "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -11733,9 +11770,9 @@ } }, "node_modules/react-i18next": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", - "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", + "version": "15.5.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.1.tgz", + "integrity": "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", @@ -11743,7 +11780,8 @@ }, "peerDependencies": { "i18next": ">= 23.2.3", - "react": ">= 16.8.0" + "react": ">= 16.8.0", + "typescript": "^5" }, "peerDependenciesMeta": { "react-dom": { @@ -11751,6 +11789,9 @@ }, "react-native": { "optional": true + }, + "typescript": { + "optional": true } } }, @@ -11971,9 +12012,9 @@ "license": "ISC" }, "node_modules/remove-trailing-spaces": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.8.tgz", - "integrity": "sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.9.tgz", + "integrity": "sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==", "dev": true, "license": "MIT" }, @@ -12087,9 +12128,9 @@ "license": "ISC" }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -12125,9 +12166,9 @@ } }, "node_modules/rimraf/node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", "dev": true, "license": "ISC", "dependencies": { @@ -12149,9 +12190,9 @@ } }, "node_modules/rimraf/node_modules/jackspeak": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.3.tgz", - "integrity": "sha512-oSwM7q8PTHQWuZAlp995iPpPJ4Vkl7qT0ZRD+9duL9j2oBy6KcTfyxc8mEuHJYC+z/kbps80aJLkaNzTOrf/kw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", + "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -12165,9 +12206,9 @@ } }, "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", - "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", "dev": true, "license": "ISC", "engines": { @@ -12208,9 +12249,9 @@ } }, "node_modules/rollup": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", - "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", + "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", "dev": true, "license": "MIT", "dependencies": { @@ -12224,26 +12265,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.0", - "@rollup/rollup-android-arm64": "4.40.0", - "@rollup/rollup-darwin-arm64": "4.40.0", - "@rollup/rollup-darwin-x64": "4.40.0", - "@rollup/rollup-freebsd-arm64": "4.40.0", - "@rollup/rollup-freebsd-x64": "4.40.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", - "@rollup/rollup-linux-arm-musleabihf": "4.40.0", - "@rollup/rollup-linux-arm64-gnu": "4.40.0", - "@rollup/rollup-linux-arm64-musl": "4.40.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-musl": "4.40.0", - "@rollup/rollup-linux-s390x-gnu": "4.40.0", - "@rollup/rollup-linux-x64-gnu": "4.40.0", - "@rollup/rollup-linux-x64-musl": "4.40.0", - "@rollup/rollup-win32-arm64-msvc": "4.40.0", - "@rollup/rollup-win32-ia32-msvc": "4.40.0", - "@rollup/rollup-win32-x64-msvc": "4.40.0", + "@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", "fsevents": "~2.3.2" } }, @@ -12292,9 +12333,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "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": { @@ -12529,9 +12570,9 @@ } }, "node_modules/smol-toml": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.1.tgz", - "integrity": "sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.4.tgz", + "integrity": "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12635,9 +12676,9 @@ } }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "dev": true, "license": "MIT" }, @@ -12676,16 +12717,16 @@ "license": "MIT" }, "node_modules/storybook-react-i18next": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/storybook-react-i18next/-/storybook-react-i18next-3.2.1.tgz", - "integrity": "sha512-TmFFy8scDynoK61fawEm+ey8L0mRZYJYO/KmuAqittZ6ZBOH/qQtvR8a4qiTgmOXnE39wO3/YnWXzkm/0kYfbQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/storybook-react-i18next/-/storybook-react-i18next-3.3.1.tgz", + "integrity": "sha512-xZ4ESBAh11kxv5878JSLWJP6e95DgpGDkFaUjbmkq72k3Omw7OnPn9RzcwL1XetPzAu03RIMcBmGpj2bTDexsw==", "dev": true, "license": "MIT", "dependencies": { "storybook-i18n": "3.1.1" }, "peerDependencies": { - "i18next": "^22.0.0 || ^23.0.0 || ^24.0.0", + "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" @@ -12701,15 +12742,6 @@ "streamx": "^2.13.2" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/streamx": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", @@ -12905,9 +12937,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.20.7", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.7.tgz", - "integrity": "sha512-gLpb1wrWinUwMFKfSvDYsIlCyGQSryftzi6uWc9Qo98zO3mFT6oHOqmDUu5OoahvepuS6HGTe/3MsGUCVtpLig==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz", + "integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -13219,13 +13251,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2" }, "engines": { @@ -13393,13 +13425,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.19.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -13412,454 +13444,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" - } - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -13870,6 +13454,19 @@ "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", @@ -13936,9 +13533,9 @@ } }, "node_modules/undici": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.5.tgz", - "integrity": "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "dev": true, "license": "MIT", "dependencies": { @@ -13955,6 +13552,16 @@ "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/universal-user-agent": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", @@ -14013,9 +13620,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -14125,9 +13732,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "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==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -14192,16 +13799,6 @@ "node": ">= 10.13.0" } }, - "node_modules/value-or-promise": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", - "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -14328,18 +13925,18 @@ } }, "node_modules/vite": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.0.tgz", - "integrity": "sha512-9aC0n4pr6hIbvi1YOpFjwQ+QOTGssvbJKoeYkuHHGWwlXfdxQlI8L2qNMo9awEEcCPSiS+5mJZk5jH1PAqoDeQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -14403,15 +14000,15 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.3.tgz", + "integrity": "sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", + "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, @@ -14456,14 +14053,11 @@ } }, "node_modules/vite-plugin-graphql-codegen": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.5.0.tgz", - "integrity": "sha512-K9SDS30BTdzHEjPDlfyB2K7zTBSrB8jBWxM+uR1Jo/bIZPT5IZKbZeTS2R1fGTIFbAuYUf4AWOVMsOZ4v/XcTw==", + "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==", "dev": true, "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0" - }, "peerDependencies": { "@graphql-codegen/cli": ">=1.0.0 <6.0.0", "graphql": ">=14.0.0 <17.0.0", @@ -14478,31 +14072,32 @@ "license": "MIT" }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.3.tgz", + "integrity": "sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/expect": "3.1.3", + "@vitest/mocker": "3.1.3", + "@vitest/pretty-format": "^3.1.3", + "@vitest/runner": "3.1.3", + "@vitest/snapshot": "3.1.3", + "@vitest/spy": "3.1.3", + "@vitest/utils": "3.1.3", "chai": "^5.2.0", "debug": "^4.4.0", - "expect-type": "^1.2.0", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite-node": "3.1.3", "why-is-node-running": "^2.3.0" }, "bin": { @@ -14518,8 +14113,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.1.3", + "@vitest/ui": "3.1.3", "happy-dom": "*", "jsdom": "*" }, @@ -14548,14 +14143,14 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz", + "integrity": "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/spy": "3.1.3", + "@vitest/utils": "3.1.3", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -14564,9 +14159,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz", + "integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==", "dev": true, "license": "MIT", "dependencies": { @@ -14577,9 +14172,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz", + "integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14590,13 +14185,13 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.3", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -14653,6 +14248,13 @@ "node": "*" } }, + "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==", + "dev": true, + "license": "ISC" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -14835,9 +14437,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", "dev": true, "license": "MIT", "engines": { @@ -14884,9 +14486,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", "dev": true, "license": "ISC", "bin": { @@ -14959,9 +14561,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", "dev": true, "license": "MIT", "funding": { diff --git a/frontend/package.json b/frontend/package.json index 782c03469..7a1acf5dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,21 +20,21 @@ "dependencies": { "@fontsource/inconsolata": "^5.2.5", "@fontsource/inter": "^5.2.5", - "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", - "@tanstack/react-query": "^5.74.3", - "@tanstack/react-router": "^1.116.0", - "@vector-im/compound-design-tokens": "4.0.1", - "@vector-im/compound-web": "^7.10.1", + "@radix-ui/react-collapsible": "^1.1.8", + "@radix-ui/react-dialog": "^1.1.11", + "@tanstack/react-query": "^5.75.5", + "@tanstack/react-router": "^1.120.2", + "@vector-im/compound-design-tokens": "4.0.2", + "@vector-im/compound-web": "^7.10.2", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "i18next": "^24.2.3", + "i18next": "^25.1.2", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-i18next": "^15.4.1", - "swagger-ui-dist": "^5.20.7", + "react-i18next": "^15.5.1", + "swagger-ui-dist": "^5.21.0", "valibot": "^1.0.0", "vaul": "^1.1.2" }, @@ -44,49 +44,51 @@ "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", "@codecov/vite-plugin": "^1.9.0", - "@graphql-codegen/cli": "^5.0.5", + "@graphql-codegen/cli": "^5.0.6", "@graphql-codegen/client-preset": "^4.8.0", "@graphql-codegen/typescript-msw": "^3.0.0", "@storybook/addon-essentials": "^8.6.12", "@storybook/addon-interactions": "^8.6.12", "@storybook/react": "^8.6.12", "@storybook/react-vite": "^8.6.12", - "@storybook/test": "^8.5.5", - "@tanstack/react-query-devtools": "^5.74.3", - "@tanstack/react-router-devtools": "^1.116.0", - "@tanstack/router-plugin": "^1.116.1", + "@storybook/test": "^8.6.12", + "@tanstack/react-query-devtools": "^5.75.5", + "@tanstack/react-router-devtools": "^1.120.2", + "@tanstack/router-plugin": "^1.120.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.14.1", - "@types/react": "19.1.1", - "@types/react-dom": "19.1.2", + "@types/node": "^22.15.16", + "@types/react": "19.1.3", + "@types/react-dom": "19.1.3", "@types/swagger-ui-dist": "^3.30.5", - "@vitejs/plugin-react": "^4.4.0", - "@vitest/coverage-v8": "^3.1.1", + "@vitejs/plugin-react": "^4.4.1", + "@vitest/coverage-v8": "^3.1.3", "autoprefixer": "^10.4.21", "browserslist-to-esbuild": "^2.1.1", - "graphql": "^16.10.0", - "happy-dom": "^17.4.4", + "graphql": "^16.11.0", + "happy-dom": "^17.4.6", "i18next-parser": "^9.3.0", - "knip": "^5.50.2", - "msw": "^2.7.4", + "knip": "^5.54.1", + "msw": "^2.7.5", "msw-storybook-addon": "^2.0.4", "postcss": "^8.5.3", "postcss-import": "^16.1.0", "postcss-nesting": "^13.0.1", "rimraf": "^6.0.1", - "storybook": "^8.5.5", - "storybook-react-i18next": "^3.2.1", + "storybook": "^8.6.12", + "storybook-react-i18next": "^3.3.1", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", - "vite": "6.3.0", + "vite": "6.3.5", "vite-plugin-compression": "^0.5.1", - "vite-plugin-graphql-codegen": "^3.5.0", + "vite-plugin-graphql-codegen": "^3.6.1", "vite-plugin-manifest-sri": "^0.2.0", - "vitest": "^3.0.5" + "vitest": "^3.1.2" }, "msw": { - "workerDirectory": [".storybook/public"] + "workerDirectory": [ + ".storybook/public" + ] } } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 758e96193..ded4085a3 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -370,6 +370,10 @@ type CompatSession implements Node & CreationEvent { The last time the session was active. """ lastActiveAt: DateTime + """ + A human-provided name for the session. + """ + humanName: String } type CompatSessionConnection { @@ -937,7 +941,13 @@ type Mutation { input: CreateOAuth2SessionInput! ): CreateOAuth2SessionPayload! endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload! + setOauth2SessionName( + input: SetOAuth2SessionNameInput! + ): SetOAuth2SessionNamePayload! endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload! + setCompatSessionName( + input: SetCompatSessionNameInput! + ): SetCompatSessionNamePayload! endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload! """ Set the display name of a user @@ -1060,6 +1070,10 @@ type Oauth2Session implements Node & CreationEvent { The last time the session was active. """ lastActiveAt: DateTime + """ + The user-provided name for this session. + """ + humanName: String } type Oauth2SessionConnection { @@ -1426,6 +1440,45 @@ type SetCanRequestAdminPayload { user: User } +""" +The input of the `setCompatSessionName` mutation. +""" +input SetCompatSessionNameInput { + """ + The ID of the session to set the name of. + """ + compatSessionId: ID! + """ + The new name of the session. + """ + humanName: String! +} + +type SetCompatSessionNamePayload { + """ + The status of the mutation. + """ + status: SetCompatSessionNameStatus! + """ + The session that was updated. + """ + oauth2Session: CompatSession +} + +""" +The status of the `setCompatSessionName` mutation. +""" +enum SetCompatSessionNameStatus { + """ + The session was updated. + """ + UPDATED + """ + The session was not found. + """ + NOT_FOUND +} + """ The input for the `addEmail` mutation """ @@ -1468,6 +1521,45 @@ enum SetDisplayNameStatus { INVALID } +""" +The input of the `setOauth2SessionName` mutation. +""" +input SetOAuth2SessionNameInput { + """ + The ID of the session to set the name of. + """ + oauth2SessionId: ID! + """ + The new name of the session. + """ + humanName: String! +} + +type SetOAuth2SessionNamePayload { + """ + The status of the mutation. + """ + status: SetOAuth2SessionNameStatus! + """ + The session that was updated. + """ + oauth2Session: Oauth2Session +} + +""" +The status of the `setOauth2SessionName` mutation. +""" +enum SetOAuth2SessionNameStatus { + """ + The session was updated. + """ + UPDATED + """ + The session was not found. + """ + NOT_FOUND +} + """ The input for the `setPasswordByRecovery` mutation. """ diff --git a/frontend/src/components/CompatSession.tsx b/frontend/src/components/CompatSession.tsx index 2ea3fdd60..2770993ad 100644 --- a/frontend/src/components/CompatSession.tsx +++ b/frontend/src/components/CompatSession.tsx @@ -22,6 +22,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session userAgent { name @@ -42,9 +43,11 @@ const CompatSession: React.FC<{ const { t } = useTranslation(); const data = useFragment(FRAGMENT, session); - const clientName = data.ssoLogin?.redirectUri - ? simplifyUrl(data.ssoLogin.redirectUri) - : undefined; + const clientName = + data.humanName ?? + (data.ssoLogin?.redirectUri + ? simplifyUrl(data.ssoLogin.redirectUri) + : undefined); const deviceType = data.userAgent?.deviceType ?? "UNKNOWN"; diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx index cc92f26c6..a72fa4aba 100644 --- a/frontend/src/components/OAuth2Session.tsx +++ b/frontend/src/components/OAuth2Session.tsx @@ -16,6 +16,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session @@ -72,6 +73,7 @@ const OAuth2Session: React.FC = ({ session }) => { const clientName = data.client.clientName || data.client.clientId; const deviceName = + data.humanName ?? data.userAgent?.model ?? (data.userAgent?.name ? data.userAgent?.os diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx index 30fb72418..9645c9e71 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx @@ -6,6 +6,7 @@ // @vitest-environment happy-dom +import { TooltipProvider } from "@vector-im/compound-web"; import { beforeAll, describe, expect, it } from "vitest"; import { makeFragmentData } from "../../gql"; import { mockLocale } from "../../test-utils/mockLocale"; @@ -33,7 +34,9 @@ describe("", () => { const data = makeFragmentData({ ...baseSession }, FRAGMENT); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); @@ -51,7 +54,9 @@ describe("", () => { ); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); @@ -69,7 +74,9 @@ describe("", () => { ); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx index 144e7ef37..17101a07b 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx @@ -4,17 +4,28 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { VisualList } from "@vector-im/compound-web"; import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import simplifyUrl from "../../utils/simplifyUrl"; import DateTime from "../DateTime"; import EndCompatSessionButton from "../Session/EndCompatSessionButton"; import LastActive from "../Session/LastActive"; +import EditSessionName from "./EditSessionName"; import SessionHeader from "./SessionHeader"; import * as Info from "./SessionInfo"; +const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ ` + mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) { + setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) { + status + } + } +`); + export const FRAGMENT = graphql(/* GraphQL */ ` fragment CompatSession_detail on CompatSession { id @@ -23,6 +34,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session @@ -46,6 +58,19 @@ type Props = { const CompatSessionDetail: React.FC = ({ session }) => { const data = useFragment(FRAGMENT, session); const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const setDisplayName = useMutation({ + mutationFn: (displayName: string) => + graphqlRequest({ + query: SET_SESSION_NAME_MUTATION, + variables: { sessionId: data.id, displayName }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] }); + queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] }); + }, + }); const deviceName = data.userAgent?.model ?? @@ -62,10 +87,13 @@ const CompatSessionDetail: React.FC = ({ session }) => { ? simplifyUrl(data.ssoLogin.redirectUri) : data.deviceId || data.id; + const sessionName = data.humanName ?? `${clientName}: ${deviceName}`; + return (
- {clientName}: {deviceName} + {sessionName} + @@ -141,10 +169,12 @@ const CompatSessionDetail: React.FC = ({ session }) => { {deviceName} - - {t("frontend.session.uri_label")} - {data.ssoLogin?.redirectUri} - + {data.ssoLogin && ( + + {t("frontend.session.uri_label")} + {data.ssoLogin?.redirectUri} + + )} diff --git a/frontend/src/components/SessionDetail/EditSessionName.tsx b/frontend/src/components/SessionDetail/EditSessionName.tsx new file mode 100644 index 000000000..4a57c34e8 --- /dev/null +++ b/frontend/src/components/SessionDetail/EditSessionName.tsx @@ -0,0 +1,100 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import IconEdit from "@vector-im/compound-design-tokens/assets/web/icons/edit"; +import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web"; +import { + type ComponentPropsWithoutRef, + forwardRef, + useRef, + useState, +} from "react"; +import * as Dialog from "../Dialog"; +import LoadingSpinner from "../LoadingSpinner"; + +import type { UseMutationResult } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +// This needs to be its own component because else props and refs aren't passed properly in the trigger +const EditButton = forwardRef< + HTMLButtonElement, + { label: string } & ComponentPropsWithoutRef<"button"> +>(({ label, ...props }, ref) => ( + + + + + +)); + +type Props = { + mutation: UseMutationResult; + deviceName: string; +}; + +const EditSessionName: React.FC = ({ mutation, deviceName }) => { + const { t } = useTranslation(); + const fieldRef = useRef(null); + const [open, setOpen] = useState(false); + + const onSubmit = async ( + event: React.FormEvent, + ): Promise => { + event.preventDefault(); + + const form = event.currentTarget; + const formData = new FormData(form); + const displayName = formData.get("name") as string; + await mutation.mutateAsync(displayName); + setOpen(false); + }; + return ( + } + open={open} + onOpenChange={(open) => { + // Reset the form when the dialog is opened or closed + fieldRef.current?.form?.reset(); + setOpen(open); + }} + > + {t("frontend.session.set_device_name.title")} + + + + {t("frontend.session.set_device_name.label")} + + + + + {t("frontend.session.set_device_name.help")} + + + + + {mutation.isPending && } + {t("action.save")} + + + + + + + + ); +}; + +export default EditSessionName; diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx index 7f33da2bb..8aa60c6bd 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx @@ -11,6 +11,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { makeFragmentData } from "../../gql"; import { mockLocale } from "../../test-utils/mockLocale"; +import { TooltipProvider } from "@vector-im/compound-web"; import render from "../../test-utils/render"; import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail"; @@ -39,7 +40,9 @@ describe("", () => { const data = makeFragmentData(baseSession, FRAGMENT); const { asFragment, getByText, queryByText } = render( - , + + + , ); expect(asFragment()).toMatchSnapshot(); @@ -57,7 +60,9 @@ describe("", () => { ); const { asFragment, getByText, queryByText } = render( - , + + + , ); expect(asFragment()).toMatchSnapshot(); diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx index 656067cd0..2dc850d43 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx @@ -4,17 +4,28 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope"; import DateTime from "../DateTime"; import ClientAvatar from "../Session/ClientAvatar"; import EndOAuth2SessionButton from "../Session/EndOAuth2SessionButton"; import LastActive from "../Session/LastActive"; +import EditSessionName from "./EditSessionName"; import SessionHeader from "./SessionHeader"; import * as Info from "./SessionInfo"; +const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ ` + mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) { + setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) { + status + } + } +`); + export const FRAGMENT = graphql(/* GraphQL */ ` fragment OAuth2Session_detail on Oauth2Session { id @@ -23,6 +34,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session @@ -49,11 +61,25 @@ type Props = { const OAuth2SessionDetail: React.FC = ({ session }) => { const data = useFragment(FRAGMENT, session); const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const setDisplayName = useMutation({ + mutationFn: (displayName: string) => + graphqlRequest({ + query: SET_SESSION_NAME_MUTATION, + variables: { sessionId: data.id, displayName }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] }); + queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] }); + }, + }); const deviceId = getDeviceIdFromScope(data.scope); const clientName = data.client.clientName || data.client.clientId; const deviceName = + data.humanName ?? data.userAgent?.model ?? (data.userAgent?.name ? data.userAgent?.os @@ -68,7 +94,9 @@ const OAuth2SessionDetail: React.FC = ({ session }) => {
{clientName}: {deviceName} + + {t("frontend.session.title")} diff --git a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap index 2fe6298f3..fbfd98192 100644 --- a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap +++ b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap @@ -27,9 +27,38 @@ exports[` > renders a compatability session details 1`] = `

- element.io - : - Unknown device + element.io: Unknown device +

> renders a compatability session details 1`] = `
> renders a compatability session without an ssoL Unknown device

-
  • -
    - Uri -
    -

    -

  • > renders a finished session details 1`] = ` class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" > Element: Unknown device +
    > renders session details 1`] = ` class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" > Element: Unknown device +
    > renders session details 1`] = `