diff --git a/.github/actions/build-policies/action.yml b/.github/actions/build-policies/action.yml index e1dc28547..6264047ef 100644 --- a/.github/actions/build-policies/action.yml +++ b/.github/actions/build-policies/action.yml @@ -13,7 +13,7 @@ runs: uses: open-policy-agent/setup-opa@v2.2.0 with: # Keep in sync with the Dockerfile and policies/Makefile - version: 1.8.0 + version: 1.13.1 - name: Build the policies run: make diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 087edb118..cc8d2a5bf 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -271,7 +271,7 @@ jobs: mirrors = ["mirror.gcr.io"] - name: Login to GitHub Container Registry - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8dc71d0d6..ba90f2be1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: uses: StyraInc/setup-regal@v1 with: # Keep in sync with policies/Makefile - version: 0.36.1 + version: 0.38.1 - name: Lint policies working-directory: ./policies @@ -64,7 +64,7 @@ jobs: uses: actions/checkout@v6 - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 @@ -88,7 +88,7 @@ jobs: uses: actions/checkout@v6 - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 @@ -112,7 +112,7 @@ jobs: uses: actions/checkout@v6 - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 @@ -216,7 +216,7 @@ jobs: uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.89.0 + uses: dtolnay/rust-toolchain@1.93.0 with: components: clippy diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 3053b56fb..6b0ec68b9 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -39,7 +39,7 @@ jobs: tool: mdbook - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index e058a62be..0b46539be 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -64,7 +64,7 @@ jobs: uses: actions/checkout@v6 - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 70419fce0..e964bf371 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v6 - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 @@ -42,7 +42,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v8.0.0 + uses: peter-evans/create-pull-request@v8.1.0 with: sign-commits: true token: ${{ secrets.BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index ba723278e..453a0d46d 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v6 - name: Install Node - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24 diff --git a/Cargo.lock b/Cargo.lock index ba314ee8c..66fc14d49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,9 +197,12 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] [[package]] name = "argon2" @@ -677,9 +680,9 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bcrypt" -version = "0.17.1" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" +checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827" dependencies = [ "base64", "blowfish", @@ -805,9 +808,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -882,6 +885,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -1736,7 +1745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2779,14 +2788,15 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.2" +version = "1.46.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" dependencies = [ "console", "once_cell", "serde", "similar", + "tempfile", ] [[package]] @@ -3097,7 +3107,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "axum", @@ -3131,7 +3141,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "axum", @@ -3206,7 +3216,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "camino", @@ -3237,7 +3247,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.10.0" +version = "1.11.0" dependencies = [ "console", "opentelemetry", @@ -3253,7 +3263,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.10.0" +version = "1.11.0" dependencies = [ "base64ct", "chrono", @@ -3276,7 +3286,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.10.0" +version = "1.11.0" dependencies = [ "async-trait", "lettre", @@ -3287,7 +3297,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.10.0" +version = "1.11.0" dependencies = [ "aide", "anyhow", @@ -3367,7 +3377,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.10.0" +version = "1.11.0" dependencies = [ "futures-util", "headers", @@ -3388,7 +3398,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.10.0" +version = "1.11.0" dependencies = [ "camino", "icu_calendar", @@ -3410,7 +3420,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.10.0" +version = "1.11.0" dependencies = [ "camino", "clap", @@ -3424,7 +3434,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.10.0" +version = "1.11.0" dependencies = [ "schemars 0.9.0", "serde", @@ -3432,7 +3442,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "async-trait", @@ -3449,7 +3459,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.10.0" +version = "1.11.0" dependencies = [ "base64ct", "chrono", @@ -3479,7 +3489,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.10.0" +version = "1.11.0" dependencies = [ "aead", "base64ct", @@ -3507,7 +3517,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "bytes", @@ -3531,7 +3541,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "async-trait", @@ -3541,7 +3551,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "async-trait", @@ -3558,7 +3568,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.10.0" +version = "1.11.0" dependencies = [ "assert_matches", "async-trait", @@ -3594,7 +3604,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "arc-swap", @@ -3611,7 +3621,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.10.0" +version = "1.11.0" dependencies = [ "axum", "serde", @@ -3622,7 +3632,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.10.0" +version = "1.11.0" dependencies = [ "camino", "serde", @@ -3631,7 +3641,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.10.0" +version = "1.11.0" dependencies = [ "async-trait", "chrono", @@ -3653,7 +3663,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.10.0" +version = "1.11.0" dependencies = [ "async-trait", "chrono", @@ -3683,7 +3693,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "async-trait", @@ -3715,7 +3725,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "arc-swap", @@ -3747,7 +3757,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.10.0" +version = "1.11.0" dependencies = [ "http", "opentelemetry", @@ -3950,9 +3960,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -4016,7 +4026,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.10.0" +version = "1.11.0" dependencies = [ "assert_matches", "base64ct", @@ -4086,7 +4096,7 @@ dependencies = [ "sha1", "sha2", "sprintf", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tracing", "urlencoding", @@ -4708,7 +4718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -4968,9 +4978,9 @@ checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -5146,7 +5156,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5418,10 +5428,11 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "sentry" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9794f69ad475e76c057e326175d3088509649e3aed98473106b9fe94ba59424" +checksum = "d92d893ba7469d361a6958522fa440e4e2bc8bf4c5803cd1bf40b9af63f8f9a8" dependencies = [ + "cfg_aliases", "httpdate", "reqwest", "sentry-backtrace", @@ -5435,9 +5446,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e81137ad53b8592bd0935459ad74c0376053c40084aa170451e74eeea8dbc6c3" +checksum = "5f8784d0a27b5cd4b5f75769ffc84f0b7580e3c35e1af9cd83cb90b612d769cc" dependencies = [ "backtrace", "regex", @@ -5446,9 +5457,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb403c66cc2651a01b9bacda2e7c22cd51f7e8f56f206aa4310147eb3259282" +checksum = "0e5eb42f4cd4f9fdfec9e3b07b25a4c9769df83d218a7e846658984d5948ad3e" dependencies = [ "hostname", "libc", @@ -5460,9 +5471,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc409727ae90765ca8ea76fe6c949d6f159a11d02e130b357fa652ee9efcada" +checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06" dependencies = [ "rand 0.9.2", "sentry-types", @@ -5473,9 +5484,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df79f4e1e72b2a8b75a0ebf49e78709ceb9b3f0b451f13adc92a0361b0aaabe" +checksum = "8906f8be87aea5ac7ef937323fb655d66607427f61007b99b7cb3504dc5a156c" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5483,9 +5494,9 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec9885bceb8ba374858d015bb6fa39dbb341d94ca088bc8f13bee2e64e2c68" +checksum = "56aebe376310840b49dad4cca55c7b32d9abdc14946cd071d4158ecb149b63a4" dependencies = [ "axum", "http", @@ -5498,9 +5509,9 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2046f527fd4b75e0b6ab3bd656c67dce42072f828dc4d03c206d15dca74a93" +checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9" dependencies = [ "bitflags", "sentry-backtrace", @@ -5511,9 +5522,9 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.46.0" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b9b4e4c03a4d3643c18c78b8aa91d2cbee5da047d2fa0ca4bb29bc67e6c55c" +checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259" dependencies = [ "debugid", "hex", @@ -6120,7 +6131,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.10.0" +version = "1.11.0" dependencies = [ "anyhow", "arc-swap", @@ -6191,7 +6202,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6276,31 +6287,32 @@ dependencies = [ [[package]] name = "time" -version = "0.3.43" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -6491,9 +6503,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -6531,9 +6543,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -6555,9 +6567,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -6566,9 +6578,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -6616,9 +6628,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -7318,7 +7330,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 274b9510b..718343ab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.10.0" +package.version = "1.11.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -34,35 +34,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.10.0" } -mas-cli = { path = "./crates/cli/", version = "=1.10.0" } -mas-config = { path = "./crates/config/", version = "=1.10.0" } -mas-context = { path = "./crates/context/", version = "=1.10.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.10.0" } -mas-email = { path = "./crates/email/", version = "=1.10.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.10.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.10.0" } -mas-http = { path = "./crates/http/", version = "=1.10.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.10.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.10.0" } -mas-iana = { path = "./crates/iana/", version = "=1.10.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.10.0" } -mas-jose = { path = "./crates/jose/", version = "=1.10.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.10.0" } -mas-listener = { path = "./crates/listener/", version = "=1.10.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.10.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.10.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.10.0" } -mas-policy = { path = "./crates/policy/", version = "=1.10.0" } -mas-router = { path = "./crates/router/", version = "=1.10.0" } -mas-spa = { path = "./crates/spa/", version = "=1.10.0" } -mas-storage = { path = "./crates/storage/", version = "=1.10.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.10.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.10.0" } -mas-templates = { path = "./crates/templates/", version = "=1.10.0" } -mas-tower = { path = "./crates/tower/", version = "=1.10.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.10.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.10.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.11.0" } +mas-cli = { path = "./crates/cli/", version = "=1.11.0" } +mas-config = { path = "./crates/config/", version = "=1.11.0" } +mas-context = { path = "./crates/context/", version = "=1.11.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.11.0" } +mas-email = { path = "./crates/email/", version = "=1.11.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.11.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.11.0" } +mas-http = { path = "./crates/http/", version = "=1.11.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.11.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.11.0" } +mas-iana = { path = "./crates/iana/", version = "=1.11.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.11.0" } +mas-jose = { path = "./crates/jose/", version = "=1.11.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.11.0" } +mas-listener = { path = "./crates/listener/", version = "=1.11.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.11.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.11.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.11.0" } +mas-policy = { path = "./crates/policy/", version = "=1.11.0" } +mas-router = { path = "./crates/router/", version = "=1.11.0" } +mas-spa = { path = "./crates/spa/", version = "=1.11.0" } +mas-storage = { path = "./crates/storage/", version = "=1.11.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.11.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.11.0" } +mas-templates = { path = "./crates/templates/", version = "=1.11.0" } +mas-tower = { path = "./crates/tower/", version = "=1.11.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.11.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.11.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] @@ -71,7 +71,7 @@ features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"] # An `Arc` that can be atomically updated [workspace.dependencies.arc-swap] -version = "1.7.1" +version = "1.8.1" # GraphQL server [workspace.dependencies.async-graphql] @@ -124,7 +124,7 @@ features = ["std"] # Bcrypt password hashing [workspace.dependencies.bcrypt] -version = "0.17.1" +version = "0.18.0" default-features = true # Packed bitfields @@ -330,7 +330,7 @@ version = "2.0.6" # Snapshot testing [workspace.dependencies.insta] -version = "1.43.2" +version = "1.46.3" features = ["yaml", "json"] # IP network address types @@ -567,7 +567,7 @@ features = [ # Sentry error tracking [workspace.dependencies.sentry] -version = "0.46.0" +version = "0.46.2" default-features = false features = ["backtrace", "contexts", "panic", "tower", "reqwest"] @@ -685,7 +685,7 @@ features = ["cors", "fs", "add-extension", "set-header"] [workspace.dependencies.tracing] version = "0.1.41" [workspace.dependencies.tracing-subscriber] -version = "0.3.20" +version = "0.3.22" features = ["env-filter"] [workspace.dependencies.tracing-appender] version = "0.2.4" diff --git a/Dockerfile b/Dockerfile index 479dee308..ba2ed16c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -# syntax = docker/dockerfile:1.7.1 +# syntax = docker/dockerfile:1.21.0 +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -10,13 +11,14 @@ # being the platform being built. # The Debian version and version name must be in sync -ARG DEBIAN_VERSION=12 -ARG DEBIAN_VERSION_NAME=bookworm -ARG RUSTC_VERSION=1.89.0 -ARG NODEJS_VERSION=24.11.0 +ARG DEBIAN_VERSION=13 +ARG DEBIAN_VERSION_NAME=trixie +# Keep in sync with .github/workflows/ci.yaml +ARG RUSTC_VERSION=1.93.0 +ARG NODEJS_VERSION=24.13.0 # Keep in sync with .github/actions/build-policies/action.yml and policies/Makefile -ARG OPA_VERSION=1.8.0 -ARG CARGO_AUDITABLE_VERSION=0.7.0 +ARG OPA_VERSION=1.13.1 +ARG CARGO_AUDITABLE_VERSION=0.7.2 ########################################## ## Build stage that builds the frontend ## diff --git a/crates/config/src/sections/clients.rs b/crates/config/src/sections/clients.rs index 8b9120045..2387d642d 100644 --- a/crates/config/src/sections/clients.rs +++ b/crates/config/src/sections/clients.rs @@ -16,19 +16,6 @@ use url::Url; use super::{ClientSecret, ClientSecretRaw, ConfigurationSection}; -#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] -#[serde(rename_all = "snake_case")] -pub enum JwksOrJwksUri { - Jwks(PublicJsonWebKeySet), - JwksUri(Url), -} - -impl From for JwksOrJwksUri { - fn from(jwks: PublicJsonWebKeySet) -> Self { - Self::Jwks(jwks) - } -} - /// Authentication method used by clients #[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)] #[serde(rename_all = "snake_case")] diff --git a/crates/config/src/sections/email.rs b/crates/config/src/sections/email.rs index 3df0c99db..8c365e3e5 100644 --- a/crates/config/src/sections/email.rs +++ b/crates/config/src/sections/email.rs @@ -14,15 +14,6 @@ use serde::{Deserialize, Serialize, de::Error}; use super::ConfigurationSection; -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct Credentials { - /// Username for use to authenticate when connecting to the SMTP server - pub username: String, - - /// Password for use to authenticate when connecting to the SMTP server - pub password: String, -} - /// Encryption mode to use #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] diff --git a/crates/handlers/src/cleanup_tests.rs b/crates/handlers/src/cleanup_tests.rs new file mode 100644 index 000000000..7b1a74f53 --- /dev/null +++ b/crates/handlers/src/cleanup_tests.rs @@ -0,0 +1,774 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Integration tests for session cleanup jobs. +//! +//! These tests verify that cleanup jobs correctly respect the session hierarchy +//! required for OIDC Backchannel Logout to function properly. +//! +//! Session hierarchy: +//! ```text +//! upstream_oauth_authorization_sessions (matched by sub/sid claims) +//! │ user_session_id +//! ▼ +//! user_sessions (browser sessions) +//! │ user_session_id FK +//! ┌────┴──────────────┐ +//! │ │ +//! ▼ ▼ +//! compat_sessions oauth2_sessions +//! ``` + +use chrono::Duration; +use hyper::{Request, StatusCode}; +use mas_data_model::{ + BrowserSession, Clock as _, CompatSession, Device, UpstreamOAuthAuthorizationSession, + UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User, +}; +use mas_iana::jose::JsonWebSignatureAlg; +use mas_jose::jwt::{JsonWebSignatureHeader, Jwt}; +use mas_storage::{ + RepositoryAccess, + queue::{DeactivateUserJob, QueueJobRepositoryExt}, + upstream_oauth2::UpstreamOAuthProviderParams, +}; +use oauth2_types::scope::{OPENID, Scope}; +use sqlx::PgPool; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, +}; + +use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + +/// Helper struct to hold all the entities created for testing the session +/// hierarchy. +struct TestSessionHierarchy { + user: User, + browser_session: BrowserSession, + compat_session: Option, + oauth2_session: Option, + upstream_session: Option, + #[expect(dead_code)] + upstream_link: Option, + provider: Option, + #[expect(dead_code)] + mock_server: MockServer, +} + +const UPSTREAM_OAUTH_ISSUER: &str = "https://idp.example.com"; +const UPSTREAM_OAUTH_CLIENT_ID: &str = "test-client"; +const UPSTREAM_OAUTH_SESSION_ID: &str = "upstream-oauth-session-id"; +const UPSTREAM_OAUTH_SUBJECT: &str = "upstream-oauth-sub"; + +/// Create the complete session hierarchy for testing. +/// +/// This creates: +/// - A user +/// - A browser session +/// - Optionally a compat session linked to the browser session +/// - Optionally an OAuth 2.0 session linked to the browser session +/// - Optionally an upstream OAuth session linked to the browser session +async fn create_session_hierarchy( + state: &TestState, + with_compat: bool, + with_oauth2: bool, + with_upstream: bool, +) -> TestSessionHierarchy { + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + // Start a mock server to answer to JWKS requests for the backchannel logout + // tests + let mock_server = MockServer::start().await; + + let jwks = state.key_store.public_jwks(); + + let mock_jwks = Mock::given(method("GET")) + .and(path("jwks.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(jwks)); + mock_server.register(mock_jwks).await; + + // Create user + let user = repo + .user() + .add(&mut rng, &state.clock, "testuser".to_owned()) + .await + .unwrap(); + + // Create browser session + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + + // Create compat session if requested + let compat_session = if with_compat { + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add( + &mut rng, + &state.clock, + &user, + device, + Some(&browser_session), + false, + None, + ) + .await + .unwrap(); + Some(session) + } else { + None + }; + + // Create OAuth2 session if requested + let oauth2_session = if with_oauth2 { + // First create an OAuth2 client + let client = repo + .oauth2_client() + .add( + &mut rng, + &state.clock, + vec!["https://example.com/callback".parse().unwrap()], + None, + None, + None, + vec![], + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + + let session = repo + .oauth2_session() + .add_from_browser_session( + &mut rng, + &state.clock, + &client, + &browser_session, + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + Some(session) + } else { + None + }; + + // Create upstream OAuth session if requested + let (provider, upstream_link, upstream_session) = if with_upstream { + let params = UpstreamOAuthProviderParams { + issuer: Some(UPSTREAM_OAUTH_ISSUER.to_owned()), + human_name: Some("Test IdP".to_owned()), + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + client_id: UPSTREAM_OAUTH_CLIENT_ID.to_owned(), + encrypted_client_secret: None, + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + // Point to the mock server to have it use a JWKS we can use for signing + jwks_uri_override: Some(format!("{}/jwks.json", mock_server.uri()).parse().unwrap()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Disabled, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + additional_authorization_parameters: vec![], + forward_login_hint: false, + ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::LogoutAll, + }; + + let provider = repo + .upstream_oauth_provider() + .add(&mut rng, &state.clock, params) + .await + .unwrap(); + + // Create a link + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + UPSTREAM_OAUTH_SUBJECT.to_owned(), + Some("test@idp.example.com".to_owned()), + ) + .await + .unwrap(); + + // Associate link to user + repo.upstream_oauth_link() + .associate_to_user(&link, &user) + .await + .unwrap(); + + // Create an upstream session + let session = repo + .upstream_oauth_session() + .add( + &mut rng, + &state.clock, + &provider, + "state123".to_owned(), + Some("verifier123".to_owned()), + Some("nonce123".to_owned()), + ) + .await + .unwrap(); + + // Complete the session with the link and ID token claims (including sub and + // sid) + let id_token_claims = serde_json::json!({ + "sub": UPSTREAM_OAUTH_SUBJECT, + "sid": UPSTREAM_OAUTH_SESSION_ID, + }); + let session = repo + .upstream_oauth_session() + .complete_with_link( + &state.clock, + session, + &link, + Some("fake-id-token".to_owned()), + Some(id_token_claims), + None, + None, + ) + .await + .unwrap(); + + // Consume the session and link it to the browser session + let session = repo + .upstream_oauth_session() + .consume(&state.clock, session, &browser_session) + .await + .unwrap(); + + (Some(provider), Some(link), Some(session)) + } else { + (None, None, None) + }; + + repo.save().await.unwrap(); + + TestSessionHierarchy { + user, + browser_session, + compat_session, + oauth2_session, + upstream_session, + upstream_link, + provider, + mock_server, + } +} + +/// Test that sessions finished less than 30 days ago are NOT deleted. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_cleanup_sessions_within_retention_preserved(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create a full hierarchy with all session types + let hierarchy = create_session_hierarchy(&state, true, true, false).await; + + // Finish all sessions + let mut repo = state.repository().await.unwrap(); + let browser_session = repo + .browser_session() + .finish(&state.clock, hierarchy.browser_session) + .await + .unwrap(); + let compat_session = repo + .compat_session() + .finish(&state.clock, hierarchy.compat_session.unwrap()) + .await + .unwrap(); + let oauth2_session = repo + .oauth2_session() + .finish(&state.clock, hierarchy.oauth2_session.unwrap()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Wait one day and run the cleanup jobs a few times + state.clock.advance(Duration::try_days(1).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + // Verify all sessions still exist + let mut repo = state.repository().await.unwrap(); + assert!( + repo.browser_session() + .lookup(browser_session.id) + .await + .unwrap() + .is_some(), + "Browser session should still exist" + ); + assert!( + repo.compat_session() + .lookup(compat_session.id) + .await + .unwrap() + .is_some(), + "Compat session should still exist" + ); + assert!( + repo.oauth2_session() + .lookup(oauth2_session.id) + .await + .unwrap() + .is_some(), + "OAuth2 session should still exist" + ); +} + +/// Test that deactivated users eventually get all their sessions cleaned up +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_cleanup_deactivated_users(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + + // Create a hierarchy with all session types + let hierarchy = create_session_hierarchy(&state, true, true, true).await; + + // Deactivate the user + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .lookup(hierarchy.user.id) + .await + .unwrap() + .unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.queue_job() + .schedule_job(&mut rng, &state.clock, DeactivateUserJob::new(&user, false)) + .await + .unwrap(); + repo.save().await.unwrap(); + + state.run_jobs_in_queue().await; + + // Verify all sessions are finished + let mut repo = state.repository().await.unwrap(); + assert!( + repo.compat_session() + .lookup(hierarchy.compat_session.as_ref().unwrap().id) + .await + .unwrap() + .unwrap() + .is_finished(), + "Compat session should be finished" + ); + assert!( + repo.oauth2_session() + .lookup(hierarchy.oauth2_session.as_ref().unwrap().id) + .await + .unwrap() + .unwrap() + .is_finished(), + "OAuth2 session should be finished" + ); + assert!( + repo.browser_session() + .lookup(hierarchy.browser_session.id) + .await + .unwrap() + .unwrap() + .finished_at + .is_some(), + "Browser session should be there" + ); + assert!( + repo.upstream_oauth_session() + .lookup(hierarchy.upstream_session.as_ref().unwrap().id) + .await + .unwrap() + .is_some(), + "Upstream OAuth session should be there" + ); + + // Wait 31 days and run the cleanup jobs a few times + state.clock.advance(Duration::try_days(31).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + // Verify all sessions are deleted + let mut repo = state.repository().await.unwrap(); + assert!( + repo.compat_session() + .lookup(hierarchy.compat_session.as_ref().unwrap().id) + .await + .unwrap() + .is_none(), + "Compat session should be deleted" + ); + assert!( + repo.oauth2_session() + .lookup(hierarchy.oauth2_session.as_ref().unwrap().id) + .await + .unwrap() + .is_none(), + "OAuth2 session should be deleted" + ); + assert!( + repo.browser_session() + .lookup(hierarchy.browser_session.id) + .await + .unwrap() + .is_none(), + "Browser session should be deleted" + ); + assert!( + repo.upstream_oauth_session() + .lookup(hierarchy.upstream_session.as_ref().unwrap().id) + .await + .unwrap() + .is_none(), + "Upstream OAuth session should be deleted" + ); +} + +/// Test that sessions finished more than 30 days ago ARE deleted. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_cleanup_sessions_after_retention_deleted(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create hierarchy with compat and oauth2 sessions + let hierarchy = create_session_hierarchy(&state, true, true, false).await; + + // Finish all sessions + let mut repo = state.repository().await.unwrap(); + let browser_session = repo + .browser_session() + .finish(&state.clock, hierarchy.browser_session) + .await + .unwrap(); + let compat_session = repo + .compat_session() + .finish(&state.clock, hierarchy.compat_session.unwrap()) + .await + .unwrap(); + let oauth2_session = repo + .oauth2_session() + .finish(&state.clock, hierarchy.oauth2_session.unwrap()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Wait 31 days and run the cleanup jobs a few times + state.clock.advance(Duration::try_days(31).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + // Verify all sessions are deleted + let mut repo = state.repository().await.unwrap(); + assert!( + repo.compat_session() + .lookup(compat_session.id) + .await + .unwrap() + .is_none(), + "Compat session should be deleted" + ); + assert!( + repo.oauth2_session() + .lookup(oauth2_session.id) + .await + .unwrap() + .is_none(), + "OAuth2 session should be deleted" + ); + // Browser session should also be deleted since children are gone + assert!( + repo.browser_session() + .lookup(browser_session.id) + .await + .unwrap() + .is_none(), + "Browser session should be deleted after children are gone" + ); +} + +/// Test that user sessions with remaining child sessions are NOT deleted. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_cleanup_user_session_blocked_by_child_sessions(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create hierarchy with compat session only + let hierarchy = create_session_hierarchy(&state, true, false, false).await; + + // Finish only the browser session (not the compat session) + let mut repo = state.repository().await.unwrap(); + let browser_session = repo + .browser_session() + .finish(&state.clock, hierarchy.browser_session) + .await + .unwrap(); + repo.save().await.unwrap(); + + let compat_session_id = hierarchy.compat_session.as_ref().unwrap().id; + + // Wait 31 days and run the cleanup jobs a few times + state.clock.advance(Duration::try_days(31).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + // Verify browser session still exists because compat session is still active + let mut repo = state.repository().await.unwrap(); + assert!( + repo.browser_session() + .lookup(browser_session.id) + .await + .unwrap() + .is_some(), + "Browser session should NOT be deleted because it has an active child session" + ); + assert!( + repo.compat_session() + .lookup(compat_session_id) + .await + .unwrap() + .is_some(), + "Compat session should still exist (not finished)" + ); +} + +/// Test that backchannel logout can find sessions before cleanup. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_backchannel_logout_works_before_cleanup(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create hierarchy with upstream session + let hierarchy = create_session_hierarchy(&state, true, true, true).await; + + let provider = hierarchy.provider.as_ref().unwrap(); + + // The edge case we're trying to make works, is that if the browser session + // is finished for 30 days but *not* the child sessions, that browser + // session and the upstream sessions stay there so that backchannel logout + // still works + let mut repo = state.repository().await.unwrap(); + let browser_session = repo + .browser_session() + .lookup(hierarchy.browser_session.id) + .await + .unwrap() + .unwrap(); + repo.browser_session() + .finish(&state.clock, browser_session) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Now wait 31 days and run the cleanup jobs a few times + state.clock.advance(Duration::try_days(31).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + // Now let's craft a backchannel logout request + let ts = state.clock.now().timestamp(); + let payload = serde_json::json!({ + "iss": UPSTREAM_OAUTH_ISSUER, + "aud": UPSTREAM_OAUTH_CLIENT_ID, + "sub": UPSTREAM_OAUTH_SUBJECT, + "sid": UPSTREAM_OAUTH_SESSION_ID, + "jti": "iswearthisisrandom", + "iat": ts, + "exp": ts + 300, + "events": { + "http://schemas.openid.net/event/backchannel-logout": {} + } + }); + + let key = state + .key_store + .signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256) + .unwrap(); + let signer = key + .params() + .signing_key_for_alg(&JsonWebSignatureAlg::Rs256) + .unwrap(); + let signed = Jwt::sign( + JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256), + payload, + &signer, + ) + .unwrap(); + + let request = Request::post(format!("/upstream/backchannel-logout/{}", provider.id)).form( + serde_json::json!({ + "logout_token": signed.as_str(), + }), + ); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + // The session should still exist, but are finished + let mut repo = state.repository().await.unwrap(); + assert!( + !repo + .browser_session() + .lookup(hierarchy.browser_session.id) + .await + .unwrap() + .unwrap() + .active(), + "Inactive browser session should not be cleaned up" + ); + assert!( + repo.compat_session() + .lookup(hierarchy.compat_session.as_ref().unwrap().id) + .await + .unwrap() + .unwrap() + .is_finished(), + "Active compat session should not be cleaned up" + ); + assert!( + repo.oauth2_session() + .lookup(hierarchy.oauth2_session.as_ref().unwrap().id) + .await + .unwrap() + .unwrap() + .is_finished(), + "Active OAuth2 session should not be cleaned up" + ); + + // Wait again, then the sessions should be completely deleted + state.clock.advance(Duration::try_days(31).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + let mut repo = state.repository().await.unwrap(); + assert!( + repo.browser_session() + .lookup(hierarchy.browser_session.id) + .await + .unwrap() + .is_none(), + "Browser session should be deleted" + ); + assert!( + repo.compat_session() + .lookup(hierarchy.compat_session.as_ref().unwrap().id) + .await + .unwrap() + .is_none(), + "Compat session should be deleted" + ); + assert!( + repo.oauth2_session() + .lookup(hierarchy.oauth2_session.as_ref().unwrap().id) + .await + .unwrap() + .is_none(), + "OAuth2 session should be deleted" + ); + assert!( + repo.upstream_oauth_session() + .lookup(hierarchy.upstream_session.as_ref().unwrap().id) + .await + .unwrap() + .is_none(), + "Upstream OAuth session should be deleted" + ); +} + +/// Test that active sessions are not cleaned up even after retention period. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_active_sessions_not_cleaned_up(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create hierarchy without finishing any sessions + let hierarchy = create_session_hierarchy(&state, true, true, false).await; + + let browser_session_id = hierarchy.browser_session.id; + let compat_session_id = hierarchy.compat_session.as_ref().unwrap().id; + let oauth2_session_id = hierarchy.oauth2_session.as_ref().unwrap().id; + + // Wait 31 days and run the cleanup jobs a few times + state.clock.advance(Duration::try_days(31).unwrap()); + state.run_jobs_in_queue().await; + for _ in 0..5 { + state.clock.advance(Duration::try_hours(1).unwrap()); + state.run_jobs_in_queue().await; + } + + // All sessions should still exist because they're active + let mut repo = state.repository().await.unwrap(); + assert!( + repo.browser_session() + .lookup(browser_session_id) + .await + .unwrap() + .is_some(), + "Active browser session should not be cleaned up" + ); + assert!( + repo.compat_session() + .lookup(compat_session_id) + .await + .unwrap() + .is_some(), + "Active compat session should not be cleaned up" + ); + assert!( + repo.oauth2_session() + .lookup(oauth2_session_id) + .await + .unwrap() + .is_some(), + "Active OAuth2 session should not be cleaned up" + ); +} diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 84c8f8525..df059cd36 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -83,7 +83,7 @@ pub async fn get( Some(CompatLoginSsoAction::Register) => { url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id)) } - Some(CompatLoginSsoAction::Login) | None => { + Some(CompatLoginSsoAction::Login | CompatLoginSsoAction::Unknown) | None => { url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id)) } }; @@ -224,7 +224,7 @@ pub async fn post( Some(CompatLoginSsoAction::Register) => { url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id)) } - Some(CompatLoginSsoAction::Login) | None => { + Some(CompatLoginSsoAction::Login | CompatLoginSsoAction::Unknown) | None => { url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id)) } }; diff --git a/crates/handlers/src/compat/login_sso_redirect.rs b/crates/handlers/src/compat/login_sso_redirect.rs index 1ad47c55e..8edb868fd 100644 --- a/crates/handlers/src/compat/login_sso_redirect.rs +++ b/crates/handlers/src/compat/login_sso_redirect.rs @@ -13,7 +13,6 @@ use mas_router::{CompatLoginSsoAction, CompatLoginSsoComplete, UrlBuilder}; use mas_storage::{BoxRepository, compat::CompatSsoLoginRepository}; use rand::distributions::{Alphanumeric, DistString}; use serde::Deserialize; -use serde_with::serde; use thiserror::Error; use url::Url; @@ -23,12 +22,21 @@ use crate::impl_from_error_for_route; pub struct Params { #[serde(rename = "redirectUrl")] redirect_url: Option, + action: Option, #[serde(rename = "org.matrix.msc3824.action")] unstable_action: Option, } +impl Params { + fn action(&self) -> Option { + self.action + .filter(CompatLoginSsoAction::is_known) + .or(self.unstable_action.filter(CompatLoginSsoAction::is_known)) + } +} + #[derive(Debug, Error)] pub enum RouteError { #[error(transparent)] @@ -62,6 +70,8 @@ pub async fn get( State(url_builder): State, Query(params): Query, ) -> Result { + let action = params.action(); + // Check the redirectUrl parameter let redirect_url = params.redirect_url.ok_or(RouteError::MissingRedirectUrl)?; let redirect_url = Url::parse(&redirect_url).map_err(|_| RouteError::InvalidRedirectUrl)?; @@ -84,10 +94,7 @@ pub async fn get( repo.save().await?; - Ok(url_builder.absolute_redirect(&CompatLoginSsoComplete::new( - login.id, - params.action.or(params.unstable_action), - ))) + Ok(url_builder.absolute_redirect(&CompatLoginSsoComplete::new(login.id, action))) } #[cfg(test)] @@ -121,4 +128,29 @@ mod tests { assert!(location.contains("org.matrix.msc3824.action=register")); assert!(location.contains("action=register")); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unknown_action(pool: PgPool) { + let state: TestState = TestState::from_pool(pool).await.unwrap(); + + let request = Request::get( + "/_matrix/client/v3/login/sso/redirect?\ + redirectUrl=http://example.com/\ + &org.matrix.msc3824.action=undefinedaction", + ) + .empty(); + + let response = state.request(request).await; + + response.assert_status(StatusCode::SEE_OTHER); + + let location = response + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap(); + assert!(!location.contains("org.matrix.msc3824.action")); + assert!(!location.contains("action")); + } } diff --git a/crates/handlers/src/compat/mod.rs b/crates/handlers/src/compat/mod.rs index 1c30d5e04..dd5600afe 100644 --- a/crates/handlers/src/compat/mod.rs +++ b/crates/handlers/src/compat/mod.rs @@ -25,6 +25,9 @@ pub(crate) mod logout; pub(crate) mod logout_all; pub(crate) mod refresh; +#[cfg(test)] +mod tests; + #[derive(Debug, Serialize)] struct MatrixError { errcode: &'static str, diff --git a/crates/handlers/src/compat/refresh.rs b/crates/handlers/src/compat/refresh.rs index 95679af1d..dabdaefe7 100644 --- a/crates/handlers/src/compat/refresh.rs +++ b/crates/handlers/src/compat/refresh.rs @@ -156,7 +156,7 @@ pub(crate) async fn post( .await?; repo.compat_refresh_token() - .consume(&clock, refresh_token) + .consume_and_replace(&clock, refresh_token, &new_refresh_token) .await?; if let Some(access_token) = access_token { diff --git a/crates/handlers/src/compat/tests.rs b/crates/handlers/src/compat/tests.rs new file mode 100644 index 000000000..cb6b76aba --- /dev/null +++ b/crates/handlers/src/compat/tests.rs @@ -0,0 +1,233 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use axum::http::{Request, StatusCode}; +use mas_matrix::{HomeserverConnection, ProvisionRequest}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum LoginCredentials { + #[serde(rename = "m.login.password")] + Password { + identifier: LoginIdentifier, + password: String, + }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum LoginIdentifier { + #[serde(rename = "m.id.user")] + User { user: String }, +} + +#[derive(Debug, Serialize)] +struct LoginRequest { + #[serde(flatten)] + credentials: LoginCredentials, + #[serde(default)] + refresh_token: bool, +} + +#[derive(Debug, Deserialize)] +struct LoginResponse { + #[allow(dead_code)] + access_token: String, + #[allow(dead_code)] + user_id: String, + #[allow(dead_code)] + device_id: Option, + refresh_token: Option, +} + +#[derive(Debug, Serialize)] +struct RefreshRequest { + refresh_token: String, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct RefreshResponse { + access_token: String, + refresh_token: String, + expires_in_ms: i64, +} + +/// Test using a compatibility refresh token. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_compat_refresh(pool: sqlx::PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create a user + create_test_user(&state, "testuser").await; + + // Login to get initial tokens + let login_request = Request::post("/_matrix/client/v3/login").json(&LoginRequest { + credentials: LoginCredentials::Password { + identifier: LoginIdentifier::User { + user: "testuser".to_owned(), + }, + password: "password".to_owned(), + }, + refresh_token: true, + }); + + let login_response = state.request(login_request).await; + login_response.assert_status(StatusCode::OK); + + let login_response: LoginResponse = login_response.json(); + let initial_refresh_token = login_response + .refresh_token + .expect("Login should return a refresh token"); + + // First refresh + let refresh_request = Request::post("/_matrix/client/v3/refresh").json(&RefreshRequest { + refresh_token: initial_refresh_token.clone(), + }); + + let first_refresh_response = state.request(refresh_request).await; + first_refresh_response.assert_status(StatusCode::OK); + + let first_refresh_response: RefreshResponse = first_refresh_response.json(); + let first_new_refresh_token = first_refresh_response.refresh_token.clone(); + + assert_eq!( + first_refresh_response, + RefreshResponse { + access_token: "mct_fNbm5KAQovodfVQz7IvDc44woP66fR_fsaiD1".to_owned(), + refresh_token: "mcr_42oTpLoieH5IecxG6gZXyvelQWW9Xq_a8g5N3".to_owned(), + expires_in_ms: 300_000 + } + ); + + // Use the token from the /refresh response to /refresh again, + // proving that it works. + // This is a regression test: we were previously consuming the refresh token + // before it was returned from /refresh. + let second_refresh_request = + Request::post("/_matrix/client/v3/refresh").json(&RefreshRequest { + refresh_token: first_new_refresh_token.clone(), + }); + + let second_refresh_response = state.request(second_refresh_request).await; + second_refresh_response.assert_status(StatusCode::OK); + + let second_refresh_response: RefreshResponse = second_refresh_response.json(); + + assert_eq!( + second_refresh_response, + RefreshResponse { + access_token: "mct_Wc6Hx4l9DGzqGtgLoYqtrtBUBcWlE4_ZFyTp2".to_owned(), + refresh_token: "mcr_Yp7FM44zJN5qePGMLvvMXC4Ds1A3lC_0YcYCM".to_owned(), + expires_in_ms: 300_000 + } + ); +} + +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_refresh_with_invalid_token(pool: sqlx::PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let refresh_request = RefreshRequest { + refresh_token: "invalid_token".to_owned(), + }; + + let refresh_request = Request::post("/_matrix/client/v3/refresh").json(&refresh_request); + + let response = state.request(refresh_request).await; + response.assert_status(StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_refresh_with_consumed_token(pool: sqlx::PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Create a user and login + create_test_user(&state, "testuser").await; + + let login_request = LoginRequest { + credentials: LoginCredentials::Password { + identifier: LoginIdentifier::User { + user: "testuser".to_owned(), + }, + password: "password".to_owned(), + }, + refresh_token: true, + }; + + let login_request = Request::post("/_matrix/client/v3/login").json(&login_request); + + let login_response = state.request(login_request).await; + login_response.assert_status(StatusCode::OK); + + let login_response: LoginResponse = login_response.json(); + let refresh_token = login_response + .refresh_token + .expect("Login should return a refresh token"); + + let refresh_request = RefreshRequest { + refresh_token: refresh_token.clone(), + }; + + // Use the refresh token once + let first_refresh_request = Request::post("/_matrix/client/v3/refresh").json(&refresh_request); + let first_refresh_response = state.request(first_refresh_request).await; + first_refresh_response.assert_status(StatusCode::OK); + + let _first_refresh_response: RefreshResponse = first_refresh_response.json(); + + // Try to use the same refresh token again - should fail because it's consumed + let second_refresh_request = Request::post("/_matrix/client/v3/refresh").json(&refresh_request); + + let second_refresh_response = state.request(second_refresh_request).await; + second_refresh_response.assert_status(StatusCode::UNAUTHORIZED); +} + +async fn create_test_user(state: &TestState, username: &str) -> mas_data_model::User { + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + + let user = repo + .user() + .add(&mut rng, &state.clock, username.to_owned()) + .await + .unwrap(); + + let password = Zeroizing::new("password".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + + // Provision the user on the homeserver + state + .homeserver_connection + .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .await + .unwrap(); + + repo.save().await.unwrap(); + + user +} diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index ebd223e4a..0cb450f53 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -62,6 +62,8 @@ mod views; mod activity_tracker; mod captcha; +#[cfg(test)] +mod cleanup_tests; mod preferred_language; mod rate_limit; mod session; diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 37c200aac..6aa18f13d 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -628,6 +628,16 @@ impl SimpleRoute for CompatLoginSsoRedirectIdp { pub enum CompatLoginSsoAction { Login, Register, + #[serde(other)] + Unknown, +} + +impl CompatLoginSsoAction { + /// Returns true if the action is a known action. + #[must_use] + pub fn is_known(&self) -> bool { + !matches!(self, Self::Unknown) + } } #[derive(Debug, Serialize, Deserialize, Clone, Copy)] diff --git a/crates/storage-pg/.sqlx/query-f75e44b528234dac708640ad9a111f3f6b468a91bf0d5b574795bf8c80605f19.json b/crates/storage-pg/.sqlx/query-4e64540bbffe5f4b9c4a6589012cf69eb67adaa4d40fc1910dfcd2640e32ab37.json similarity index 66% rename from crates/storage-pg/.sqlx/query-f75e44b528234dac708640ad9a111f3f6b468a91bf0d5b574795bf8c80605f19.json rename to crates/storage-pg/.sqlx/query-4e64540bbffe5f4b9c4a6589012cf69eb67adaa4d40fc1910dfcd2640e32ab37.json index 0d597ca3b..54b801174 100644 --- a/crates/storage-pg/.sqlx/query-f75e44b528234dac708640ad9a111f3f6b468a91bf0d5b574795bf8c80605f19.json +++ b/crates/storage-pg/.sqlx/query-4e64540bbffe5f4b9c4a6589012cf69eb67adaa4d40fc1910dfcd2640e32ab37.json @@ -1,15 +1,16 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE compat_refresh_tokens\n SET consumed_at = $2\n WHERE compat_session_id = $1\n AND consumed_at IS NULL\n ", + "query": "\n UPDATE compat_refresh_tokens\n SET consumed_at = $2\n WHERE compat_session_id = $1\n AND consumed_at IS NULL\n AND compat_refresh_token_id <> $3\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", - "Timestamptz" + "Timestamptz", + "Uuid" ] }, "nullable": [] }, - "hash": "f75e44b528234dac708640ad9a111f3f6b468a91bf0d5b574795bf8c80605f19" + "hash": "4e64540bbffe5f4b9c4a6589012cf69eb67adaa4d40fc1910dfcd2640e32ab37" } diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index d42c9b1af..b9ce82da4 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -437,6 +437,7 @@ mod tests { async fn test_refresh_token_repository(pool: PgPool) { const ACCESS_TOKEN: &str = "access_token"; const REFRESH_TOKEN: &str = "refresh_token"; + const REFRESH_TOKEN2: &str = "refresh_token2"; let mut rng = ChaChaRng::seed_from_u64(42); let clock = MockClock::default(); let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); @@ -508,16 +509,28 @@ mod tests { assert!(refresh_token_lookup.is_valid()); assert!(!refresh_token_lookup.is_consumed()); - // Consume it + // Consume the first token, but to do so we need a 2nd to replace it with + let refresh_token2 = repo + .compat_refresh_token() + .add( + &mut rng, + &clock, + &session, + &access_token, + REFRESH_TOKEN2.to_owned(), + ) + .await + .unwrap(); + let refresh_token = repo .compat_refresh_token() - .consume(&clock, refresh_token) + .consume_and_replace(&clock, refresh_token, &refresh_token2) .await .unwrap(); assert!(!refresh_token.is_valid()); assert!(refresh_token.is_consumed()); - // Reload it and check again + // Reload the first token and check again let refresh_token_lookup = repo .compat_refresh_token() .find_by_token(REFRESH_TOKEN) @@ -530,7 +543,7 @@ mod tests { // Consuming it again should not work assert!( repo.compat_refresh_token() - .consume(&clock, refresh_token) + .consume_and_replace(&clock, refresh_token, &refresh_token2) .await .is_err() ); diff --git a/crates/storage-pg/src/compat/refresh_token.rs b/crates/storage-pg/src/compat/refresh_token.rs index 5c6add046..3cb9d7944 100644 --- a/crates/storage-pg/src/compat/refresh_token.rs +++ b/crates/storage-pg/src/compat/refresh_token.rs @@ -185,20 +185,26 @@ impl CompatRefreshTokenRepository for PgCompatRefreshTokenRepository<'_> { } #[tracing::instrument( - name = "db.compat_refresh_token.consume", + name = "db.compat_refresh_token.consume_and_replace", skip_all, fields( db.query.text, %compat_refresh_token.id, + %successor_compat_refresh_token.id, compat_session.id = %compat_refresh_token.session_id, ), err, )] - async fn consume( + async fn consume_and_replace( &mut self, clock: &dyn Clock, compat_refresh_token: CompatRefreshToken, + successor_compat_refresh_token: &CompatRefreshToken, ) -> Result { + if compat_refresh_token.session_id != successor_compat_refresh_token.session_id { + return Err(DatabaseError::invalid_operation()); + } + let consumed_at = clock.now(); let res = sqlx::query!( r#" @@ -206,9 +212,11 @@ impl CompatRefreshTokenRepository for PgCompatRefreshTokenRepository<'_> { SET consumed_at = $2 WHERE compat_session_id = $1 AND consumed_at IS NULL + AND compat_refresh_token_id <> $3 "#, Uuid::from(compat_refresh_token.session_id), consumed_at, + Uuid::from(successor_compat_refresh_token.id), ) .traced() .execute(&mut *self.conn) diff --git a/crates/storage/src/compat/refresh_token.rs b/crates/storage/src/compat/refresh_token.rs index 6ffefbc69..d4434569c 100644 --- a/crates/storage/src/compat/refresh_token.rs +++ b/crates/storage/src/compat/refresh_token.rs @@ -69,16 +69,22 @@ pub trait CompatRefreshTokenRepository: Send + Sync { token: String, ) -> Result; - /// Consume a compat refresh token. + /// Consume the given compat refresh token, as well as all other refresh + /// tokens from the same session, except for the given successor compat + /// refresh token. /// - /// This also marks other refresh tokens in the same session as consumed. + /// The given successor refresh token will thereafter be the only valid + /// refresh token for the session. + /// + /// # Historical context + /// + /// When using a refresh token, we must be able to mark multiple other + /// refresh tokens in the same session as consumed. /// This is desirable because the syn2mas migration process can import /// multiple refresh tokens for one device (compat session). /// But once the user uses one of those, the others should no longer /// be valid. /// - /// Returns the consumed compat refresh token - /// /// # Parameters /// /// * `clock`: The clock used to generate timestamps @@ -86,11 +92,15 @@ pub trait CompatRefreshTokenRepository: Send + Sync { /// /// # Errors /// - /// Returns [`Self::Error`] if the underlying repository fails - async fn consume( + /// - Returns [`Self::Error`] if the underlying repository fails + /// - Returns an error if `compat_refresh_token` is not valid to be + /// consumed. + /// - Returns an error if no refresh tokens would be consumed. + async fn consume_and_replace( &mut self, clock: &dyn Clock, compat_refresh_token: CompatRefreshToken, + successor_compat_refresh_token: &CompatRefreshToken, ) -> Result; } @@ -111,9 +121,10 @@ repository_impl!(CompatRefreshTokenRepository: token: String, ) -> Result; - async fn consume( + async fn consume_and_replace( &mut self, clock: &dyn Clock, compat_refresh_token: CompatRefreshToken, + successor_compat_refresh_token: &CompatRefreshToken, ) -> Result; ); diff --git a/crates/tasks/src/cleanup/misc.rs b/crates/tasks/src/cleanup/misc.rs new file mode 100644 index 000000000..52fd62e5c --- /dev/null +++ b/crates/tasks/src/cleanup/misc.rs @@ -0,0 +1,88 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Miscellaneous cleanup tasks + +use std::time::Duration; + +use async_trait::async_trait; +use mas_storage::queue::{CleanupQueueJobsJob, PruneStalePolicyDataJob}; +use tracing::{debug, info}; +use ulid::Ulid; + +use super::BATCH_SIZE; +use crate::{ + State, + new_queue::{JobContext, JobError, RunnableJob}, +}; + +#[async_trait] +impl RunnableJob for CleanupQueueJobsJob { + #[tracing::instrument(name = "job.cleanup_queue_jobs", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove completed and failed queue jobs after 30 days. + // Keep them for debugging purposes. + let until = state.clock.now() - chrono::Duration::days(30); + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + let (count, cursor) = repo + .queue_job() + .cleanup(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no queue jobs to clean up"); + } else { + info!(count = total, "cleaned up queue jobs"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for PruneStalePolicyDataJob { + #[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)?; + + // Keep the last 10 policy data + let count = repo + .policy_data() + .prune(10) + .await + .map_err(JobError::retry)?; + + repo.save().await.map_err(JobError::retry)?; + + if count == 0 { + debug!("no stale policy data to prune"); + } else { + info!(count, "pruned stale policy data"); + } + + Ok(()) + } +} diff --git a/crates/tasks/src/cleanup/mod.rs b/crates/tasks/src/cleanup/mod.rs new file mode 100644 index 000000000..02ace1ded --- /dev/null +++ b/crates/tasks/src/cleanup/mod.rs @@ -0,0 +1,24 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Database cleanup tasks +//! +//! This module contains tasks for cleaning up old data from the database. +//! Tasks are grouped by domain: +//! +//! - [`tokens`]: OAuth token cleanup (access and refresh tokens) +//! - [`sessions`]: Session cleanup (compat, `OAuth2`, user sessions and their +//! IPs) +//! - [`oauth`]: OAuth grants and upstream OAuth cleanup +//! - [`user`]: User-related cleanup (registrations, recovery, email auth) +//! - [`misc`]: Miscellaneous cleanup (queue jobs, policy data) + +mod misc; +mod oauth; +mod sessions; +mod tokens; +mod user; + +pub(crate) const BATCH_SIZE: usize = 1000; diff --git a/crates/tasks/src/cleanup/oauth.rs b/crates/tasks/src/cleanup/oauth.rs new file mode 100644 index 000000000..2a201d4df --- /dev/null +++ b/crates/tasks/src/cleanup/oauth.rs @@ -0,0 +1,216 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! OAuth grants and upstream OAuth cleanup tasks + +use std::time::Duration; + +use async_trait::async_trait; +use mas_storage::queue::{ + CleanupOAuthAuthorizationGrantsJob, CleanupOAuthDeviceCodeGrantsJob, + CleanupUpstreamOAuthLinksJob, CleanupUpstreamOAuthSessionsJob, +}; +use tracing::{debug, info}; +use ulid::Ulid; + +use super::BATCH_SIZE; +use crate::{ + State, + new_queue::{JobContext, JobError, RunnableJob}, +}; + +#[async_trait] +impl RunnableJob for CleanupOAuthAuthorizationGrantsJob { + #[tracing::instrument(name = "job.cleanup_oauth_authorization_grants", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove authorization grants after 7 days. They are in practice only + // valid for a short time, but keeping them around helps investigate abuse + // patterns. + let until = state.clock.now() - chrono::Duration::days(7); + // We use the fact that ULIDs include the creation time in their first 48 bits + // as a cursor + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + // This returns the number of deleted grants, and the greatest ULID processed + let (count, cursor) = repo + .oauth2_authorization_grant() + .cleanup(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no authorization grants to clean up"); + } else { + info!(count = total, "cleaned up authorization grants"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupOAuthDeviceCodeGrantsJob { + #[tracing::instrument(name = "job.cleanup_oauth_device_code_grants", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove device code grants after 7 days. They are in practice only + // valid for a short time, but keeping them around helps investigate abuse + // patterns. + let until = state.clock.now() - chrono::Duration::days(7); + // We use the fact that ULIDs include the creation time in their first 48 bits + // as a cursor + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + // This returns the number of deleted grants, and the greatest ULID processed + let (count, cursor) = repo + .oauth2_device_code_grant() + .cleanup(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no device code grants to clean up"); + } else { + info!(count = total, "cleaned up device code grants"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupUpstreamOAuthSessionsJob { + #[tracing::instrument(name = "job.cleanup_upstream_oauth_sessions", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove pending upstream OAuth authorization sessions after 7 days. + let until = state.clock.now() - chrono::Duration::days(7); + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + let (count, cursor) = repo + .upstream_oauth_session() + .cleanup_orphaned(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no pending upstream OAuth sessions to clean up"); + } else { + info!(count = total, "cleaned up pending upstream OAuth sessions"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupUpstreamOAuthLinksJob { + #[tracing::instrument(name = "job.cleanup_upstream_oauth_links", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove orphaned upstream OAuth links after 7 days. + let until = state.clock.now() - chrono::Duration::days(7); + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + let (count, cursor) = repo + .upstream_oauth_link() + .cleanup_orphaned(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no orphaned upstream OAuth links to clean up"); + } else { + info!(count = total, "cleaned up orphaned upstream OAuth links"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} diff --git a/crates/tasks/src/cleanup/sessions.rs b/crates/tasks/src/cleanup/sessions.rs new file mode 100644 index 000000000..0a11a6b99 --- /dev/null +++ b/crates/tasks/src/cleanup/sessions.rs @@ -0,0 +1,290 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Session cleanup tasks + +use std::time::Duration; + +use async_trait::async_trait; +use mas_storage::queue::{ + CleanupFinishedCompatSessionsJob, CleanupFinishedOAuth2SessionsJob, + CleanupFinishedUserSessionsJob, CleanupInactiveCompatSessionIpsJob, + CleanupInactiveOAuth2SessionIpsJob, CleanupInactiveUserSessionIpsJob, +}; +use tracing::{debug, info}; + +use super::BATCH_SIZE; +use crate::{ + State, + new_queue::{JobContext, JobError, RunnableJob}, +}; + +#[async_trait] +impl RunnableJob for CleanupFinishedCompatSessionsJob { + #[tracing::instrument(name = "job.cleanup_finished_compat_sessions", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup compat sessions that were finished more than 30 days ago + let until = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted sessions, and the last finished_at + // timestamp + let (count, last_finished_at) = repo + .compat_session() + .cleanup_finished(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_finished_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no finished compat sessions to clean up"); + } else { + info!(count = total, "cleaned up finished compat sessions"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupFinishedOAuth2SessionsJob { + #[tracing::instrument(name = "job.cleanup_finished_oauth2_sessions", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup OAuth2 sessions that were finished more than 30 days ago + let until = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted sessions, and the last finished_at + // timestamp + let (count, last_finished_at) = repo + .oauth2_session() + .cleanup_finished(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_finished_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no finished OAuth2 sessions to clean up"); + } else { + info!(count = total, "cleaned up finished OAuth2 sessions"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupFinishedUserSessionsJob { + #[tracing::instrument(name = "job.cleanup_finished_user_sessions", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup user/browser sessions that were finished more than 30 days ago + let until = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted sessions, and the last finished_at + // timestamp. Only deletes sessions that have no child sessions + // (compat_sessions or oauth2_sessions). + let (count, last_finished_at) = repo + .browser_session() + .cleanup_finished(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_finished_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no finished user sessions to clean up"); + } else { + info!(count = total, "cleaned up finished user sessions"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupInactiveOAuth2SessionIpsJob { + #[tracing::instrument(name = "job.cleanup_inactive_oauth2_session_ips", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Clear IPs from sessions inactive for 30+ days + let threshold = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + let (count, last_active_at) = repo + .oauth2_session() + .cleanup_inactive_ips(since, threshold, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_active_at; + total += count; + + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no OAuth2 session IPs to clean up"); + } else { + info!(count = total, "cleaned up inactive OAuth2 session IPs"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupInactiveCompatSessionIpsJob { + #[tracing::instrument(name = "job.cleanup_inactive_compat_session_ips", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Clear IPs from sessions inactive for 30+ days + let threshold = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + let (count, last_active_at) = repo + .compat_session() + .cleanup_inactive_ips(since, threshold, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_active_at; + total += count; + + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no compat session IPs to clean up"); + } else { + info!(count = total, "cleaned up inactive compat session IPs"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupInactiveUserSessionIpsJob { + #[tracing::instrument(name = "job.cleanup_inactive_user_session_ips", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Clear IPs from sessions inactive for 30+ days + let threshold = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + let (count, last_active_at) = repo + .browser_session() + .cleanup_inactive_ips(since, threshold, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_active_at; + total += count; + + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no user session IPs to clean up"); + } else { + info!(count = total, "cleaned up inactive user session IPs"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + Some(Duration::from_secs(10 * 60)) + } +} diff --git a/crates/tasks/src/cleanup/tokens.rs b/crates/tasks/src/cleanup/tokens.rs new file mode 100644 index 000000000..dd91de2b9 --- /dev/null +++ b/crates/tasks/src/cleanup/tokens.rs @@ -0,0 +1,214 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! OAuth token cleanup tasks + +use std::time::Duration; + +use async_trait::async_trait; +use mas_storage::queue::{ + CleanupConsumedOAuthRefreshTokensJob, CleanupExpiredOAuthAccessTokensJob, + CleanupRevokedOAuthAccessTokensJob, CleanupRevokedOAuthRefreshTokensJob, +}; +use tracing::{debug, info}; + +use super::BATCH_SIZE; +use crate::{ + State, + new_queue::{JobContext, JobError, RunnableJob}, +}; + +#[async_trait] +impl RunnableJob for CleanupRevokedOAuthAccessTokensJob { + #[tracing::instrument(name = "job.cleanup_revoked_oauth_access_tokens", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup tokens that were revoked more than an hour ago + let until = state.clock.now() - chrono::Duration::hours(1); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted tokens, and the last revoked_at timestamp + let (count, last_revoked_at) = repo + .oauth2_access_token() + .cleanup_revoked(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_revoked_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no token to clean up"); + } else { + info!(count = total, "cleaned up revoked tokens"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupExpiredOAuthAccessTokensJob { + #[tracing::instrument(name = "job.cleanup_expired_oauth_access_tokens", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup tokens that expired more than a month ago + // It is important to keep them around for a bit because of refresh + // token idempotency. When we see a refresh token twice, we allow + // reusing it *only* if both the next refresh token and the next access + // tokens were not used. By keeping expired access tokens around for a + // month, we cannot make the *correct* decision, we will assume that the + // token wasn't used. Refer to the token refresh logic for details. + let until = state.clock.now() - chrono::Duration::days(30); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted tokens, and the last expires_at timestamp + let (count, last_expires_at) = repo + .oauth2_access_token() + .cleanup_expired(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_expires_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no token to clean up"); + } else { + info!(count = total, "cleaned up expired tokens"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + Some(Duration::from_secs(60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupRevokedOAuthRefreshTokensJob { + #[tracing::instrument(name = "job.cleanup_revoked_oauth_refresh_tokens", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup tokens that were revoked more than an hour ago + let until = state.clock.now() - chrono::Duration::hours(1); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted tokens, and the last revoked_at timestamp + let (count, last_revoked_at) = repo + .oauth2_refresh_token() + .cleanup_revoked(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_revoked_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no token to clean up"); + } else { + info!(count = total, "cleaned up revoked tokens"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupConsumedOAuthRefreshTokensJob { + #[tracing::instrument(name = "job.cleanup_consumed_oauth_refresh_tokens", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Cleanup tokens that were consumed more than an hour ago + let until = state.clock.now() - chrono::Duration::hours(1); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + + // This returns the number of deleted tokens, and the last consumed_at timestamp + let (count, last_consumed_at) = repo + .oauth2_refresh_token() + .cleanup_consumed(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + since = last_consumed_at; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no token to clean up"); + } else { + info!(count = total, "cleaned up consumed tokens"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} diff --git a/crates/tasks/src/cleanup/user.rs b/crates/tasks/src/cleanup/user.rs new file mode 100644 index 000000000..d682c1b51 --- /dev/null +++ b/crates/tasks/src/cleanup/user.rs @@ -0,0 +1,181 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! User-related cleanup tasks + +use std::time::Duration; + +use async_trait::async_trait; +use mas_storage::queue::{ + CleanupUserEmailAuthenticationsJob, CleanupUserRecoverySessionsJob, CleanupUserRegistrationsJob, +}; +use tracing::{debug, info}; +use ulid::Ulid; + +use super::BATCH_SIZE; +use crate::{ + State, + new_queue::{JobContext, JobError, RunnableJob}, +}; + +#[async_trait] +impl RunnableJob for CleanupUserRegistrationsJob { + #[tracing::instrument(name = "job.cleanup_user_registrations", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove user registrations after 30 days. They are in practice only + // valid for 1h, but keeping them around helps investigate abuse patterns. + let until = state.clock.now() - chrono::Duration::days(30); + // We use the fact that ULIDs include the creation time in their first 48 bits + // as a cursor + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + // This returns the number of deleted registrations, and the greatest ULID + // processed + let (count, cursor) = repo + .user_registration() + .cleanup(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no user registrations to clean up"); + } else { + info!(count = total, "cleaned up user registrations"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupUserRecoverySessionsJob { + #[tracing::instrument(name = "job.cleanup_user_recovery_sessions", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove recovery sessions after 7 days. They are in practice only + // valid for a short time (tickets expire after 10 minutes), but keeping + // them around helps investigate abuse patterns. + let until = state.clock.now() - chrono::Duration::days(7); + // We use the fact that ULIDs include the creation time in their first 48 bits + // as a cursor + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + // This returns the number of deleted sessions, and the greatest ULID processed + let (count, cursor) = repo + .user_recovery() + .cleanup(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no user recovery sessions to clean up"); + } else { + info!(count = total, "cleaned up user recovery sessions"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} + +#[async_trait] +impl RunnableJob for CleanupUserEmailAuthenticationsJob { + #[tracing::instrument(name = "job.cleanup_user_email_authentications", skip_all)] + async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { + // Remove email authentications after 7 days. They are in practice only + // valid for a short time (codes expire after 10 minutes), but keeping + // them around helps investigate abuse patterns. + let until = state.clock.now() - chrono::Duration::days(7); + // We use the fact that ULIDs include the creation time in their first 48 bits + // as a cursor + let until = Ulid::from_parts( + u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), + u128::MAX, + ); + let mut total = 0; + + // Run until we get cancelled. We don't schedule a retry if we get cancelled, as + // this is a scheduled job and it will end up being rescheduled later anyway. + let mut since = None; + while !context.cancellation_token.is_cancelled() { + let mut repo = state.repository().await.map_err(JobError::retry)?; + // This returns the number of deleted authentications, and the greatest ULID + // processed + let (count, cursor) = repo + .user_email() + .cleanup_authentications(since, until, BATCH_SIZE) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + since = cursor; + total += count; + + // Check how many we deleted. If we deleted exactly BATCH_SIZE, + // there might be more to delete + if count != BATCH_SIZE { + break; + } + } + + if total == 0 { + debug!("no user email authentications to clean up"); + } else { + info!(count = total, "cleaned up user email authentications"); + } + + Ok(()) + } + + fn timeout(&self) -> Option { + // This job runs every hour, so having it running it for 10 minutes is fine + Some(Duration::from_secs(10 * 60)) + } +} diff --git a/crates/tasks/src/database.rs b/crates/tasks/src/database.rs deleted file mode 100644 index c3fb46f78..000000000 --- a/crates/tasks/src/database.rs +++ /dev/null @@ -1,916 +0,0 @@ -// Copyright 2025, 2026 Element Creations Ltd. -// Copyright 2024, 2025 New Vector Ltd. -// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -// Please see LICENSE files in the repository root for full details. - -//! Database-related tasks - -use std::time::Duration; - -use async_trait::async_trait; -use mas_storage::queue::{ - CleanupConsumedOAuthRefreshTokensJob, CleanupExpiredOAuthAccessTokensJob, - CleanupFinishedCompatSessionsJob, CleanupFinishedOAuth2SessionsJob, - CleanupFinishedUserSessionsJob, CleanupInactiveCompatSessionIpsJob, - CleanupInactiveOAuth2SessionIpsJob, CleanupInactiveUserSessionIpsJob, - CleanupOAuthAuthorizationGrantsJob, CleanupOAuthDeviceCodeGrantsJob, CleanupQueueJobsJob, - CleanupRevokedOAuthAccessTokensJob, CleanupRevokedOAuthRefreshTokensJob, - CleanupUpstreamOAuthLinksJob, CleanupUpstreamOAuthSessionsJob, - CleanupUserEmailAuthenticationsJob, CleanupUserRecoverySessionsJob, - CleanupUserRegistrationsJob, PruneStalePolicyDataJob, -}; -use tracing::{debug, info}; -use ulid::Ulid; - -use crate::{ - State, - new_queue::{JobContext, JobError, RunnableJob}, -}; - -const BATCH_SIZE: usize = 1000; - -#[async_trait] -impl RunnableJob for CleanupRevokedOAuthAccessTokensJob { - #[tracing::instrument(name = "job.cleanup_revoked_oauth_access_tokens", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup tokens that were revoked more than an hour ago - let until = state.clock.now() - chrono::Duration::hours(1); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted tokens, and the last revoked_at timestamp - let (count, last_revoked_at) = repo - .oauth2_access_token() - .cleanup_revoked(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_revoked_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no token to clean up"); - } else { - info!(count = total, "cleaned up revoked tokens"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupExpiredOAuthAccessTokensJob { - #[tracing::instrument(name = "job.cleanup_expired_oauth_access_tokens", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup tokens that expired more than a month ago - // It is important to keep them around for a bit because of refresh - // token idempotency. When we see a refresh token twice, we allow - // reusing it *only* if both the next refresh token and the next access - // tokens were not used. By keeping expired access tokens around for a - // month, we cannot make the *correct* decision, we will assume that the - // token wasn't used. Refer to the token refresh logic for details. - let until = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted tokens, and the last expires_at timestamp - let (count, last_expires_at) = repo - .oauth2_access_token() - .cleanup_expired(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_expires_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no token to clean up"); - } else { - info!(count = total, "cleaned up expired tokens"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - Some(Duration::from_secs(60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupRevokedOAuthRefreshTokensJob { - #[tracing::instrument(name = "job.cleanup_revoked_oauth_refresh_tokens", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup tokens that were revoked more than an hour ago - let until = state.clock.now() - chrono::Duration::hours(1); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted tokens, and the last revoked_at timestamp - let (count, last_revoked_at) = repo - .oauth2_refresh_token() - .cleanup_revoked(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_revoked_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no token to clean up"); - } else { - info!(count = total, "cleaned up revoked tokens"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupConsumedOAuthRefreshTokensJob { - #[tracing::instrument(name = "job.cleanup_consumed_oauth_refresh_tokens", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup tokens that were consumed more than an hour ago - let until = state.clock.now() - chrono::Duration::hours(1); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted tokens, and the last consumed_at timestamp - let (count, last_consumed_at) = repo - .oauth2_refresh_token() - .cleanup_consumed(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_consumed_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no token to clean up"); - } else { - info!(count = total, "cleaned up consumed tokens"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupUserRecoverySessionsJob { - #[tracing::instrument(name = "job.cleanup_user_recovery_sessions", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove recovery sessions after 7 days. They are in practice only - // valid for a short time (tickets expire after 10 minutes), but keeping - // them around helps investigate abuse patterns. - let until = state.clock.now() - chrono::Duration::days(7); - // We use the fact that ULIDs include the creation time in their first 48 bits - // as a cursor - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - // This returns the number of deleted sessions, and the greatest ULID processed - let (count, cursor) = repo - .user_recovery() - .cleanup(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no user recovery sessions to clean up"); - } else { - info!(count = total, "cleaned up user recovery sessions"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupUserEmailAuthenticationsJob { - #[tracing::instrument(name = "job.cleanup_user_email_authentications", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove email authentications after 7 days. They are in practice only - // valid for a short time (codes expire after 10 minutes), but keeping - // them around helps investigate abuse patterns. - let until = state.clock.now() - chrono::Duration::days(7); - // We use the fact that ULIDs include the creation time in their first 48 bits - // as a cursor - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - // This returns the number of deleted authentications, and the greatest ULID - // processed - let (count, cursor) = repo - .user_email() - .cleanup_authentications(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no user email authentications to clean up"); - } else { - info!(count = total, "cleaned up user email authentications"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupUpstreamOAuthSessionsJob { - #[tracing::instrument(name = "job.cleanup_upstream_oauth_sessions", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove pending upstream OAuth authorization sessions after 7 days. - let until = state.clock.now() - chrono::Duration::days(7); - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - let (count, cursor) = repo - .upstream_oauth_session() - .cleanup_orphaned(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no pending upstream OAuth sessions to clean up"); - } else { - info!(count = total, "cleaned up pending upstream OAuth sessions"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupUpstreamOAuthLinksJob { - #[tracing::instrument(name = "job.cleanup_upstream_oauth_links", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove orphaned upstream OAuth links after 7 days. - let until = state.clock.now() - chrono::Duration::days(7); - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - let (count, cursor) = repo - .upstream_oauth_link() - .cleanup_orphaned(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no orphaned upstream OAuth links to clean up"); - } else { - info!(count = total, "cleaned up orphaned upstream OAuth links"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupQueueJobsJob { - #[tracing::instrument(name = "job.cleanup_queue_jobs", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove completed and failed queue jobs after 30 days. - // Keep them for debugging purposes. - let until = state.clock.now() - chrono::Duration::days(30); - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - let (count, cursor) = repo - .queue_job() - .cleanup(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no queue jobs to clean up"); - } else { - info!(count = total, "cleaned up queue jobs"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupUserRegistrationsJob { - #[tracing::instrument(name = "job.cleanup_user_registrations", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove user registrations after 30 days. They are in practice only - // valid for 1h, but keeping them around helps investigate abuse patterns. - let until = state.clock.now() - chrono::Duration::days(30); - // We use the fact that ULIDs include the creation time in their first 48 bits - // as a cursor - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - // This returns the number of deleted registrations, and the greatest ULID - // processed - let (count, cursor) = repo - .user_registration() - .cleanup(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no user registrations to clean up"); - } else { - info!(count = total, "cleaned up user registrations"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupFinishedCompatSessionsJob { - #[tracing::instrument(name = "job.cleanup_finished_compat_sessions", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup compat sessions that were finished more than 30 days ago - let until = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted sessions, and the last finished_at - // timestamp - let (count, last_finished_at) = repo - .compat_session() - .cleanup_finished(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_finished_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no finished compat sessions to clean up"); - } else { - info!(count = total, "cleaned up finished compat sessions"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupFinishedOAuth2SessionsJob { - #[tracing::instrument(name = "job.cleanup_finished_oauth2_sessions", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup OAuth2 sessions that were finished more than 30 days ago - let until = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted sessions, and the last finished_at - // timestamp - let (count, last_finished_at) = repo - .oauth2_session() - .cleanup_finished(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_finished_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no finished OAuth2 sessions to clean up"); - } else { - info!(count = total, "cleaned up finished OAuth2 sessions"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupFinishedUserSessionsJob { - #[tracing::instrument(name = "job.cleanup_finished_user_sessions", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Cleanup user/browser sessions that were finished more than 30 days ago - let until = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - // This returns the number of deleted sessions, and the last finished_at - // timestamp. Only deletes sessions that have no child sessions - // (compat_sessions or oauth2_sessions). - let (count, last_finished_at) = repo - .browser_session() - .cleanup_finished(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_finished_at; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no finished user sessions to clean up"); - } else { - info!(count = total, "cleaned up finished user sessions"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupOAuthAuthorizationGrantsJob { - #[tracing::instrument(name = "job.cleanup_oauth_authorization_grants", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove authorization grants after 7 days. They are in practice only - // valid for a short time, but keeping them around helps investigate abuse - // patterns. - let until = state.clock.now() - chrono::Duration::days(7); - // We use the fact that ULIDs include the creation time in their first 48 bits - // as a cursor - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - // This returns the number of deleted grants, and the greatest ULID processed - let (count, cursor) = repo - .oauth2_authorization_grant() - .cleanup(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no authorization grants to clean up"); - } else { - info!(count = total, "cleaned up authorization grants"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupOAuthDeviceCodeGrantsJob { - #[tracing::instrument(name = "job.cleanup_oauth_device_code_grants", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Remove device code grants after 7 days. They are in practice only - // valid for a short time, but keeping them around helps investigate abuse - // patterns. - let until = state.clock.now() - chrono::Duration::days(7); - // We use the fact that ULIDs include the creation time in their first 48 bits - // as a cursor - let until = Ulid::from_parts( - u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN), - u128::MAX, - ); - let mut total = 0; - - // Run until we get cancelled. We don't schedule a retry if we get cancelled, as - // this is a scheduled job and it will end up being rescheduled later anyway. - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - // This returns the number of deleted grants, and the greatest ULID processed - let (count, cursor) = repo - .oauth2_device_code_grant() - .cleanup(since, until, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - since = cursor; - total += count; - - // Check how many we deleted. If we deleted exactly BATCH_SIZE, - // there might be more to delete - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no device code grants to clean up"); - } else { - info!(count = total, "cleaned up device code grants"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - // This job runs every hour, so having it running it for 10 minutes is fine - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for PruneStalePolicyDataJob { - #[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)?; - - // Keep the last 10 policy data - let count = repo - .policy_data() - .prune(10) - .await - .map_err(JobError::retry)?; - - repo.save().await.map_err(JobError::retry)?; - - if count == 0 { - debug!("no stale policy data to prune"); - } else { - info!(count, "pruned stale policy data"); - } - - Ok(()) - } -} - -#[async_trait] -impl RunnableJob for CleanupInactiveOAuth2SessionIpsJob { - #[tracing::instrument(name = "job.cleanup_inactive_oauth2_session_ips", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Clear IPs from sessions inactive for 30+ days - let threshold = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - let (count, last_active_at) = repo - .oauth2_session() - .cleanup_inactive_ips(since, threshold, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_active_at; - total += count; - - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no OAuth2 session IPs to clean up"); - } else { - info!(count = total, "cleaned up inactive OAuth2 session IPs"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupInactiveCompatSessionIpsJob { - #[tracing::instrument(name = "job.cleanup_inactive_compat_session_ips", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Clear IPs from sessions inactive for 30+ days - let threshold = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - let (count, last_active_at) = repo - .compat_session() - .cleanup_inactive_ips(since, threshold, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_active_at; - total += count; - - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no compat session IPs to clean up"); - } else { - info!(count = total, "cleaned up inactive compat session IPs"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - Some(Duration::from_secs(10 * 60)) - } -} - -#[async_trait] -impl RunnableJob for CleanupInactiveUserSessionIpsJob { - #[tracing::instrument(name = "job.cleanup_inactive_user_session_ips", skip_all)] - async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> { - // Clear IPs from sessions inactive for 30+ days - let threshold = state.clock.now() - chrono::Duration::days(30); - let mut total = 0; - - let mut since = None; - while !context.cancellation_token.is_cancelled() { - let mut repo = state.repository().await.map_err(JobError::retry)?; - - let (count, last_active_at) = repo - .browser_session() - .cleanup_inactive_ips(since, threshold, BATCH_SIZE) - .await - .map_err(JobError::retry)?; - repo.save().await.map_err(JobError::retry)?; - - since = last_active_at; - total += count; - - if count != BATCH_SIZE { - break; - } - } - - if total == 0 { - debug!("no user session IPs to clean up"); - } else { - info!(count = total, "cleaned up inactive user session IPs"); - } - - Ok(()) - } - - fn timeout(&self) -> Option { - Some(Duration::from_secs(10 * 60)) - } -} diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index ca339f96f..33748ae8e 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -21,7 +21,7 @@ use tokio_util::{sync::CancellationToken, task::TaskTracker}; pub use crate::new_queue::QueueWorker; -mod database; +mod cleanup; mod email; mod matrix; mod new_queue; @@ -162,125 +162,128 @@ pub async fn init( .register_handler::() .register_handler::() .register_deprecated_queue("cleanup-expired-tokens") + // Recurring jobs are spread across the hour at ~5 minute intervals + // to avoid clustering and distribute database load evenly. .add_schedule( "cleanup-revoked-oauth-access-tokens", - // Run this job every hour + // Run this job every hour at minute 0 "0 0 * * * *".parse()?, mas_storage::queue::CleanupRevokedOAuthAccessTokensJob, ) .add_schedule( "cleanup-revoked-oauth-refresh-tokens", - // Run this job every hour - "0 10 * * * *".parse()?, + // Run this job every hour at minute 5 + "0 5 * * * *".parse()?, mas_storage::queue::CleanupRevokedOAuthRefreshTokensJob, ) .add_schedule( "cleanup-consumed-oauth-refresh-tokens", - // Run this job every hour - "0 20 * * * *".parse()?, + // Run this job every hour at minute 5 (safe to parallelize with revoked) + "0 5 * * * *".parse()?, mas_storage::queue::CleanupConsumedOAuthRefreshTokensJob, ) - .add_schedule( - "cleanup-user-registrations", - // Run this job every hour - "0 30 * * * *".parse()?, - mas_storage::queue::CleanupUserRegistrationsJob, - ) .add_schedule( "cleanup-finished-compat-sessions", - // Run this job every hour - "0 40 * * * *".parse()?, + // Run this job every hour at minute 10 + "0 10 * * * *".parse()?, mas_storage::queue::CleanupFinishedCompatSessionsJob, ) .add_schedule( "cleanup-finished-oauth2-sessions", - // Run this job every hour - "0 42 * * * *".parse()?, + // Run this job every hour at minute 15 + "0 15 * * * *".parse()?, mas_storage::queue::CleanupFinishedOAuth2SessionsJob, ) .add_schedule( "cleanup-finished-user-sessions", - // Run this job every hour - "0 44 * * * *".parse()?, + // Run this job every hour at minute 20 + "0 20 * * * *".parse()?, mas_storage::queue::CleanupFinishedUserSessionsJob, ) + .add_schedule( + "cleanup-inactive-oauth2-session-ips", + // Run this job every hour at minute 25 + "0 25 * * * *".parse()?, + mas_storage::queue::CleanupInactiveOAuth2SessionIpsJob, + ) + .add_schedule( + "cleanup-inactive-compat-session-ips", + // Run this job every hour at minute 25 + "0 25 * * * *".parse()?, + mas_storage::queue::CleanupInactiveCompatSessionIpsJob, + ) + .add_schedule( + "cleanup-inactive-user-session-ips", + // Run this job every hour at minute 25 + "0 25 * * * *".parse()?, + mas_storage::queue::CleanupInactiveUserSessionIpsJob, + ) .add_schedule( "cleanup-oauth-authorization-grants", - // Run this job every hour - "0 50 * * * *".parse()?, + // Run this job every hour at minute 30 + "0 30 * * * *".parse()?, mas_storage::queue::CleanupOAuthAuthorizationGrantsJob, ) .add_schedule( "cleanup-oauth-device-code-grants", - // Run this job every hour - "0 55 * * * *".parse()?, + // Run this job every hour at minute 35 + "0 35 * * * *".parse()?, mas_storage::queue::CleanupOAuthDeviceCodeGrantsJob, ) - .add_schedule( - "cleanup-user-recovery-sessions", - // Run this job every hour - "0 56 * * * *".parse()?, - mas_storage::queue::CleanupUserRecoverySessionsJob, - ) - .add_schedule( - "cleanup-user-email-authentications", - // Run this job every hour - "0 57 * * * *".parse()?, - mas_storage::queue::CleanupUserEmailAuthenticationsJob, - ) .add_schedule( "cleanup-upstream-oauth-sessions", - // Run this job every hour - "0 58 * * * *".parse()?, + // Run this job every hour at minute 40 (independent, safe to parallelize) + "0 40 * * * *".parse()?, mas_storage::queue::CleanupUpstreamOAuthSessionsJob, ) .add_schedule( "cleanup-upstream-oauth-links", - // Run this job every hour - "0 59 * * * *".parse()?, + // Run this job every hour at minute 40 + "0 40 * * * *".parse()?, mas_storage::queue::CleanupUpstreamOAuthLinksJob, ) + // User cleanup jobs (minutes 45, 50) + .add_schedule( + "cleanup-user-registrations", + // Run this job every hour at minute 45 + "0 45 * * * *".parse()?, + mas_storage::queue::CleanupUserRegistrationsJob, + ) + .add_schedule( + "cleanup-user-recovery-sessions", + // Run this job every hour at minute 50 + "0 50 * * * *".parse()?, + mas_storage::queue::CleanupUserRecoverySessionsJob, + ) + .add_schedule( + "cleanup-user-email-authentications", + // Run this job every hour at minute 50 + "0 50 * * * *".parse()?, + mas_storage::queue::CleanupUserEmailAuthenticationsJob, + ) .add_schedule( "cleanup-queue-jobs", - // Run this job every hour - "0 45 * * * *".parse()?, + // Run this job every hour at minute 55 + "0 55 * * * *".parse()?, mas_storage::queue::CleanupQueueJobsJob, ) .add_schedule( "cleanup-expired-oauth-access-tokens", - // Run this job every 4 hours + // Run this job every 4 hours at minute 5 "0 5 */4 * * *".parse()?, mas_storage::queue::CleanupExpiredOAuthAccessTokensJob, ) .add_schedule( "expire-inactive-sessions", - // Run this job every 15 minutes + // Run this job every 15 minutes at second 30 "30 */15 * * * *".parse()?, mas_storage::queue::ExpireInactiveSessionsJob, ) .add_schedule( "prune-stale-policy-data", - // Run once a day + // Run once a day at 2:00 AM "0 0 2 * * *".parse()?, mas_storage::queue::PruneStalePolicyDataJob, - ) - .add_schedule( - "cleanup-inactive-oauth2-session-ips", - // Run this job every hour - "0 46 * * * *".parse()?, - mas_storage::queue::CleanupInactiveOAuth2SessionIpsJob, - ) - .add_schedule( - "cleanup-inactive-compat-session-ips", - // Run this job every hour - "0 47 * * * *".parse()?, - mas_storage::queue::CleanupInactiveCompatSessionIpsJob, - ) - .add_schedule( - "cleanup-inactive-user-session-ips", - // Run this job every hour - "0 48 * * * *".parse()?, - mas_storage::queue::CleanupInactiveUserSessionIpsJob, ); Ok(worker) diff --git a/docs/development/cleanup-jobs.md b/docs/development/cleanup-jobs.md index 639d0e7a3..f64cb7f21 100644 --- a/docs/development/cleanup-jobs.md +++ b/docs/development/cleanup-jobs.md @@ -11,9 +11,23 @@ Cleanup jobs are scheduled tasks that hard-delete old data from the database. Th 1. **Job struct** in `crates/storage/src/queue/tasks.rs` - Defines the job and queue name 2. **Storage trait** in `crates/storage/src/{domain}/` - Declares the cleanup method interface 3. **PostgreSQL implementation** in `crates/storage-pg/src/{domain}/` - Implements the actual cleanup logic -4. **Job runner** in `crates/tasks/src/database.rs` - Implements the `RunnableJob` trait with batching logic +4. **Job runner** in `crates/tasks/src/cleanup/` - Implements the `RunnableJob` trait with batching logic 5. **Registration** in `crates/tasks/src/lib.rs` - Registers the handler and schedules execution +### Module Structure + +The cleanup job implementations are organized into submodules by domain: + +``` +crates/tasks/src/cleanup/ +├── mod.rs # Re-exports, shared BATCH_SIZE constant +├── tokens.rs # OAuth token cleanup (access and refresh tokens) +├── sessions.rs # Session cleanup (compat, OAuth2, user sessions and their IPs) +├── oauth.rs # OAuth grants and upstream OAuth cleanup +├── user.rs # User-related cleanup (registrations, recovery, email auth) +└── misc.rs # Queue jobs, policy data cleanup +``` + ## All Cleanup Jobs | Job | Entity | Retention | Notes | @@ -183,7 +197,7 @@ The partial index (`WHERE timestamp_col IS NOT NULL`) makes queries more efficie ### 5. Implement RunnableJob -In `crates/tasks/src/database.rs`: +In the appropriate submodule under `crates/tasks/src/cleanup/` (e.g., `tokens.rs`, `sessions.rs`, `oauth.rs`, `user.rs`, or `misc.rs`): ```rust #[async_trait] diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 9b0e3684d..b0f0f3731 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.10", + projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.11", baseLocale: "en", languages: [ { @@ -181,22 +181,22 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "ru": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sv": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "uk": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "ru": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sv": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "uk": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -204,22 +204,22 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "ru": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sv": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "uk": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "zh#Hans": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "ru": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sv": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "uk": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "zh#Hans": "https://delivery.localazy.com/_a6642094778790161378d4638916/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] diff --git a/frontend/.storybook/public/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js index 558540fa5..258b1b1e1 100644 --- a/frontend/.storybook/public/mockServiceWorker.js +++ b/frontend/.storybook/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.4' +const PACKAGE_VERSION = '2.12.8' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 837f12940..694b3e2a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,7 @@ "@fontsource/inter": "^5.2.8", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", - "@tanstack/react-query": "^5.90.16", + "@tanstack/react-query": "^5.90.20", "@tanstack/react-router": "^1.150.0", "@vector-im/compound-design-tokens": "6.4.3", "@vector-im/compound-web": "^8.3.5", @@ -21,8 +21,8 @@ "classnames": "^2.5.1", "date-fns": "^4.1.0", "i18next": "^25.7.4", - "react": "^19.2.3", - "react-dom": "^19.2.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-i18next": "^16.5.3", "swagger-ui-dist": "^5.31.0", "valibot": "^1.2.0", @@ -33,34 +33,34 @@ "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", - "@graphql-codegen/cli": "^6.1.0", + "@graphql-codegen/cli": "^6.1.1", "@graphql-codegen/client-preset": "^5.2.2", "@graphql-codegen/typescript-msw": "^3.0.1", - "@storybook/addon-docs": "^10.1.11", - "@storybook/react-vite": "^10.1.11", - "@tanstack/react-query-devtools": "^5.91.2", + "@storybook/addon-docs": "^10.2.8", + "@storybook/react-vite": "^10.2.8", + "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router-devtools": "^1.150.0", "@tanstack/router-plugin": "^1.150.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.0.3", - "@types/react": "19.2.7", + "@types/node": "^25.2.3", + "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/swagger-ui-dist": "^3.30.6", - "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "^4.0.16", + "@vitejs/plugin-react": "^5.1.3", + "@vitest/coverage-v8": "^4.0.18", "autoprefixer": "^10.4.23", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.12.0", - "happy-dom": "^20.0.11", + "happy-dom": "^20.5.0", "i18next-cli": "^1.36.1", "knip": "^5.81.0", - "msw": "^2.12.4", + "msw": "^2.12.8", "msw-storybook-addon": "^2.0.6", "postcss": "^8.5.6", "postcss-import": "^16.1.1", - "postcss-nesting": "^13.0.2", + "postcss-nesting": "^14.0.0", "rimraf": "^6.1.2", "storybook": "^10.1.11", "tailwindcss": "^3.4.19", @@ -117,13 +117,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -132,9 +132,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -142,21 +142,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -173,14 +173,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -203,13 +203,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -266,29 +266,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -383,27 +383,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -921,33 +921,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -955,9 +955,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1177,9 +1177,9 @@ } }, "node_modules/@csstools/selector-resolve-nested": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", - "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", + "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", "dev": true, "funding": [ { @@ -1193,16 +1193,16 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "postcss-selector-parser": "^7.0.0" + "postcss-selector-parser": "^7.1.1" } }, "node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", "dev": true, "funding": [ { @@ -1216,10 +1216,10 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "postcss-selector-parser": "^7.0.0" + "postcss-selector-parser": "^7.1.1" } }, "node_modules/@emnapi/core": { @@ -1845,9 +1845,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/cli": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.1.0.tgz", - "integrity": "sha512-7w3Zq5IFONVOBcyOiP01Nv9WRxGS/TEaBCAb/ALYA3xHq95dqKCpoGnxt/Ut9R18jiS+aMgT0gc8Tr8sHy44jA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.1.1.tgz", + "integrity": "sha512-Ni8UdZ6D/UTvLvDtPb6PzshI0lTqtLDnmv/2t1w2SYP92H0MMEdAzxB/ujDWwIXm2LzVPvvrGvzzCTMsyXa+mA==", "dev": true, "license": "MIT", "dependencies": { @@ -3476,9 +3476,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", - "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.0.tgz", + "integrity": "sha512-edAo9bW53BLYeSK+UPRr2Iz1Fj9DeGMjytvVM0HXRoo750ElWUgPsZPAOTQa12EUiwgDErH2PsFNTLvk1jBxjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4682,9 +4682,9 @@ "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, @@ -5054,16 +5054,16 @@ "license": "MIT" }, "node_modules/@storybook/addon-docs": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.11.tgz", - "integrity": "sha512-Jwm291Fhim2eVcZIVlkG1B2skb0ZI9oru6nqMbJxceQZlvZmcIa4oxvS1oaMTKw2DJnCv97gLm57P/YvRZ8eUg==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.8.tgz", + "integrity": "sha512-cEoWqQrLzrxOwZFee5zrD4cYrdEWKV80POb7jUZO0r5vfl2DuslIr3n/+RfLT52runCV4aZcFEfOfP/IWHNPxg==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.1.11", - "@storybook/icons": "^2.0.0", - "@storybook/react-dom-shim": "10.1.11", + "@storybook/csf-plugin": "10.2.8", + "@storybook/icons": "^2.0.1", + "@storybook/react-dom-shim": "10.2.8", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -5073,18 +5073,17 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.1.11" + "storybook": "^10.2.8" } }, "node_modules/@storybook/builder-vite": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.11.tgz", - "integrity": "sha512-MMD09Ap7FyzDfWG961pkIMv/w684XXe1bBEi+wCEpHxvrgAd3j3A9w/Rqp9Am2uRDPCEdi1QgSzS3SGW3aGThQ==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.8.tgz", + "integrity": "sha512-+6/Lwi7W0YIbzHDh798GPp0IHUYDwp0yv0Y1eVNK/StZD0tnv4/1C28NKyP+O7JOsFsuWI1qHiDhw8kNURugZw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.1.11", - "@vitest/mocker": "3.2.4", + "@storybook/csf-plugin": "10.2.8", "ts-dedent": "^2.0.0" }, "funding": { @@ -5092,14 +5091,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.1.11", + "storybook": "^10.2.8", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.11.tgz", - "integrity": "sha512-Ant0NhgqHKzQsseeVTSetZCuDHHs0W2HRkHt51Kg/sUl0T/sDtfVA+fWZT8nGzGZqYSFkxqYPWjauPmIhPtaRw==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.8.tgz", + "integrity": "sha512-kKkLYhRXb33YtIPdavD2DU25sb14sqPYdcQFpyqu4TaD9truPPqW8P5PLTUgERydt/eRvRlnhauPHavU1kjsnA==", "dev": true, "license": "MIT", "dependencies": { @@ -5112,7 +5111,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.1.11", + "storybook": "^10.2.8", "vite": "*", "webpack": "*" }, @@ -5150,14 +5149,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.1.11.tgz", - "integrity": "sha512-rmMGmEwBaM2YpB8oDk2moM0MNjNMqtwyoPPZxjyruY9WVhYca8EDPGKEdRzUlb4qZJsTgLi7VU4eqg6LD/mL3Q==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.8.tgz", + "integrity": "sha512-nMFqQFUXq6Zg2O5SeuomyWnrIx61QfpNQMrfor8eCEzHrWNnXrrvVsz2RnHIgXN8RVyaWGDPh1srAECu/kDHXw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.1.11", + "@storybook/react-dom-shim": "10.2.8", "react-docgen": "^8.0.2" }, "funding": { @@ -5167,7 +5166,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.1.11", + "storybook": "^10.2.8", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -5177,9 +5176,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.11.tgz", - "integrity": "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.8.tgz", + "integrity": "sha512-Xde9X3VszFV1pTXfc2ZFM89XOCGRxJD8MUIzDwkcT9xaki5a+8srs/fsXj75fMY6gMYfcL5lNRZvCqg37HOmcQ==", "dev": true, "license": "MIT", "funding": { @@ -5189,20 +5188,20 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.1.11" + "storybook": "^10.2.8" } }, "node_modules/@storybook/react-vite": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.1.11.tgz", - "integrity": "sha512-qh1BCD25nIoiDfqwha+qBkl7pcG4WuzM+c8tsE63YEm8AFIbNKg5K8lVUoclF+4CpFz7IwBpWe61YUTDfp+91w==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.2.8.tgz", + "integrity": "sha512-x5kmw+TPhxkQV84n4e9X0q6/rA5T8V2QQFolMuN+U93q1HX1r+GZ6g/nXaaq9ox168PhHUJZQnn+LzSQKGCMBA==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.1.11", - "@storybook/react": "10.1.11", + "@storybook/builder-vite": "10.2.8", + "@storybook/react": "10.2.8", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", @@ -5216,7 +5215,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.1.11", + "storybook": "^10.2.8", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, @@ -5460,9 +5459,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -5470,9 +5469,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.92.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", - "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", "dev": true, "license": "MIT", "funding": { @@ -5481,12 +5480,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", - "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.16" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -5497,20 +5496,20 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.91.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", - "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz", + "integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.92.0" + "@tanstack/query-devtools": "5.93.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.90.14", + "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, @@ -5973,9 +5972,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5983,9 +5982,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6088,16 +6087,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -6109,18 +6108,17 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", - "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.16", - "ast-v8-to-istanbul": "^0.3.8", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", @@ -6131,8 +6129,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.16", - "vitest": "4.0.16" + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6195,47 +6193,10 @@ "node": ">=14.0.0" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/@vitest/pretty-format": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", - "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -6246,13 +6207,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", - "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.0.18", "pathe": "^2.0.3" }, "funding": { @@ -6260,13 +6221,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", - "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -6288,13 +6249,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", - "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" }, "funding": { @@ -6562,15 +6523,15 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz", - "integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { @@ -6584,9 +6545,9 @@ } }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -7641,6 +7602,19 @@ "node": ">=14" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -8527,37 +8501,23 @@ } }, "node_modules/happy-dom": { - "version": "20.0.11", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", - "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.5.0.tgz", + "integrity": "sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "^20.0.0", + "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", - "whatwg-mimetype": "^3.0.0" + "@types/ws": "^8.18.1", + "entities": "^4.5.0", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/happy-dom/node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/happy-dom/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9679,21 +9639,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -9968,9 +9913,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -10344,15 +10289,15 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.4", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.4.tgz", - "integrity": "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg==", + "version": "2.12.8", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.8.tgz", + "integrity": "sha512-KOriJUhjefCO+liF7Ie1KlSXcBAQEzuLhPZ4EKuEUSEmAR4YhuuzT9YuGxTipjqDrg6eWQ6oMoGVhvEnqukFGg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.40.0", + "@mswjs/interceptors": "^0.41.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", @@ -10362,7 +10307,7 @@ "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.7.0", + "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", @@ -11215,9 +11160,9 @@ } }, "node_modules/postcss-nesting": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", - "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-14.0.0.tgz", + "integrity": "sha512-YGFOfVrjxYfeGTS5XctP1WCI5hu8Lr9SmntjfRC+iX5hCihEO+QZl9Ra+pkjqkgoVdDKvb2JccpElcowhZtzpw==", "dev": true, "funding": [ { @@ -11231,12 +11176,12 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/selector-resolve-nested": "^3.1.0", - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" + "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-specificity": "^6.0.0", + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -11357,9 +11302,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11398,15 +11343,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-i18next": { @@ -11725,9 +11670,9 @@ } }, "node_modules/rettime": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", "dev": true, "license": "MIT" }, @@ -12151,14 +12096,14 @@ } }, "node_modules/storybook": { - "version": "10.1.11", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.11.tgz", - "integrity": "sha512-pKP5jXJYM4OjvNklGuHKO53wOCAwfx79KvZyOWHoi9zXUH5WVMFUe/ZfWyxXG/GTcj0maRgHGUjq/0I43r0dDQ==", + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.8.tgz", + "integrity": "sha512-885uSIn8NQw2ZG7vy84K45lHCOSyz1DVsDV8pHiHQj3J0riCuWLNeO50lK9z98zE8kjhgTtxAAkMTy5nkmNRKQ==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.0", + "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", @@ -12166,7 +12111,7 @@ "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", - "semver": "^7.6.2", + "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, @@ -13214,19 +13159,19 @@ } }, "node_modules/vitest": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", - "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.16", - "@vitest/mocker": "4.0.16", - "@vitest/pretty-format": "4.0.16", - "@vitest/runner": "4.0.16", - "@vitest/snapshot": "4.0.16", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -13254,10 +13199,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.16", - "@vitest/browser-preview": "4.0.16", - "@vitest/browser-webdriverio": "4.0.16", - "@vitest/ui": "4.0.16", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -13292,16 +13237,16 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", - "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -13310,13 +13255,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", - "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.16", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -13337,9 +13282,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", - "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { diff --git a/frontend/package.json b/frontend/package.json index b4a80273b..27ab4827d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "@fontsource/inter": "^5.2.8", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", - "@tanstack/react-query": "^5.90.16", + "@tanstack/react-query": "^5.90.20", "@tanstack/react-router": "^1.150.0", "@vector-im/compound-design-tokens": "6.4.3", "@vector-im/compound-web": "^8.3.5", @@ -31,8 +31,8 @@ "classnames": "^2.5.1", "date-fns": "^4.1.0", "i18next": "^25.7.4", - "react": "^19.2.3", - "react-dom": "^19.2.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-i18next": "^16.5.3", "swagger-ui-dist": "^5.31.0", "valibot": "^1.2.0", @@ -43,34 +43,34 @@ "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", - "@graphql-codegen/cli": "^6.1.0", + "@graphql-codegen/cli": "^6.1.1", "@graphql-codegen/client-preset": "^5.2.2", "@graphql-codegen/typescript-msw": "^3.0.1", - "@storybook/addon-docs": "^10.1.11", - "@storybook/react-vite": "^10.1.11", - "@tanstack/react-query-devtools": "^5.91.2", + "@storybook/addon-docs": "^10.2.8", + "@storybook/react-vite": "^10.2.8", + "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router-devtools": "^1.150.0", "@tanstack/router-plugin": "^1.150.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.0.3", - "@types/react": "19.2.7", + "@types/node": "^25.2.3", + "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/swagger-ui-dist": "^3.30.6", - "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "^4.0.16", + "@vitejs/plugin-react": "^5.1.3", + "@vitest/coverage-v8": "^4.0.18", "autoprefixer": "^10.4.23", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.12.0", - "happy-dom": "^20.0.11", + "happy-dom": "^20.5.0", "i18next-cli": "^1.36.1", "knip": "^5.81.0", - "msw": "^2.12.4", + "msw": "^2.12.8", "msw-storybook-addon": "^2.0.6", "postcss": "^8.5.6", "postcss-import": "^16.1.1", - "postcss-nesting": "^13.0.2", + "postcss-nesting": "^14.0.0", "rimraf": "^6.1.2", "storybook": "^10.1.11", "tailwindcss": "^3.4.19", diff --git a/frontend/src/components/AccountDeleteButton.tsx b/frontend/src/components/AccountDeleteButton.tsx index 6f1b80bec..a5ab30991 100644 --- a/frontend/src/components/AccountDeleteButton.tsx +++ b/frontend/src/components/AccountDeleteButton.tsx @@ -109,8 +109,8 @@ const AccountDeleteButton: React.FC = (props) => { setAllowSubmitting(false); }, [isMaybeValid]); - const onPasswordChange = useCallback( - (e: React.ChangeEvent) => { + const onPasswordInput = useCallback( + (e: React.InputEvent) => { // We don't know if the password is correct, so we consider the form as // valid if the field is not empty setIsMaybeValid(e.currentTarget.value !== ""); @@ -118,8 +118,8 @@ const AccountDeleteButton: React.FC = (props) => { [], ); - const onMxidChange = useCallback( - (e: React.ChangeEvent) => { + const onMxidInput = useCallback( + (e: React.InputEvent) => { setIsMaybeValid(e.currentTarget.value === user.matrix.mxid); }, [user.matrix.mxid], @@ -208,7 +208,7 @@ const AccountDeleteButton: React.FC = (props) => { @@ -232,7 +232,7 @@ const AccountDeleteButton: React.FC = (props) => { diff --git a/frontend/src/entrypoints/templates.ts b/frontend/src/entrypoints/templates.ts index 2e0cf93d2..77587ee58 100644 --- a/frontend/src/entrypoints/templates.ts +++ b/frontend/src/entrypoints/templates.ts @@ -10,7 +10,7 @@ // Ideally later on we could find a way to hydrate full React components instead // of doing this, as this can very quickly get out of hands. -const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/-]+|@[a-z0-9.=_/-]+(:.*)?)\s*$/g; +const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/+-]+|@[a-z0-9.=_/+-]+(:.*)?)\s*$/g; /** Grab the nearest error message inserted by the templates by error kind and code */ function grabErrorMessage( diff --git a/policies/Makefile b/policies/Makefile index db5991672..de4d063ad 100644 --- a/policies/Makefile +++ b/policies/Makefile @@ -7,9 +7,9 @@ DOCKER := 0 PODMAN := 0 # Keep in sync with Dockerfile and .github/actions/build-policies/action.yml -OPA_DOCKER_IMAGE := docker.io/openpolicyagent/opa:1.8.0-debug +OPA_DOCKER_IMAGE := docker.io/openpolicyagent/opa:1.13.1 # Keep in sync with .github/workflows/ci.yaml -REGAL_DOCKER_IMAGE := ghcr.io/open-policy-agent/regal:0.36.1 +REGAL_DOCKER_IMAGE := ghcr.io/open-policy-agent/regal:0.38.1 INPUTS := \ common/common.rego \ diff --git a/policies/register/register.rego b/policies/register/register.rego index 89002a075..93ef86a0d 100644 --- a/policies/register/register.rego +++ b/policies/register/register.rego @@ -49,7 +49,7 @@ violation contains { "field": "username", "code": "username-invalid-chars", "msg": "username contains invalid characters", } if { - not regex.match(`^[a-z0-9.=_/-]+$`, input.username) + not regex.match(`^[a-z0-9.=_/+-]+$`, input.username) } violation contains {