diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2a25d3f0b..9726f6b5b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -97,7 +97,7 @@ jobs: tool: cargo-zigbuild - name: Install frontend Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -240,7 +240,7 @@ jobs: # For pull-requests, only read from the cache, do not try to push to the # cache or the image itself - name: Build - uses: docker/bake-action@v6.2.0 + uses: docker/bake-action@v6.3.0 if: github.event_name == 'pull_request' with: files: | @@ -253,7 +253,7 @@ jobs: - name: Build and push id: bake - uses: docker/bake-action@v6.2.0 + uses: docker/bake-action@v6.3.0 if: github.event_name != 'pull_request' with: files: | @@ -306,7 +306,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version-file: ./tools/syn2mas/.nvmrc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d1a80b2cb..770f796d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -61,7 +61,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -86,7 +86,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -184,7 +184,7 @@ jobs: uses: mozilla-actions/sccache-action@v0.0.7 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -321,7 +321,7 @@ jobs: tool: cargo-nextest - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -367,7 +367,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version-file: ./tools/syn2mas/.nvmrc diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 48202149c..81da600f0 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -36,7 +36,7 @@ jobs: run: make coverage - name: Upload to codecov.io - uses: codecov/codecov-action@v5.2.0 + uses: codecov/codecov-action@v5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: policies/coverage.json @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -74,7 +74,7 @@ jobs: run: npm run coverage - name: Upload to codecov.io - uses: codecov/codecov-action@v5.2.0 + uses: codecov/codecov-action@v5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} directory: frontend/coverage/ @@ -117,7 +117,7 @@ jobs: rustup component add llvm-tools-preview - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 @@ -161,7 +161,7 @@ jobs: grcov . --binary-path ./target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov - name: Upload to codecov.io - uses: codecov/codecov-action@v5.2.0 + uses: codecov/codecov-action@v5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: target/coverage/*.lcov diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 28a21ebb6..8d70a5968 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -36,7 +36,7 @@ jobs: tool: mdbook - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index 8874eccc3..6c83b928d 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 44f1e2cbf..f42f9cc51 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index f62fbf54e..0a52dd3be 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v4.2.0 with: node-version: 20 diff --git a/Cargo.lock b/Cargo.lock index 9f1ba107e..38b48f285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "aide" -version = "0.13.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5678d2978845ddb4bd736a026f467dd652d831e9e6254b0e41b07f7ee7523309" +checksum = "0d4787eac8d785c99d76058a086b151006f5a872bc8adea8364108a39a518ac2" dependencies = [ "aide-macros", "axum", @@ -99,8 +99,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "serde_qs", - "thiserror 1.0.69", + "thiserror 2.0.11", "tower-layer", "tower-service", "tracing", @@ -108,12 +107,11 @@ dependencies = [ [[package]] name = "aide-macros" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0487f8598afe49e6bc950a613a678bd962c4a6f431022ded62643c8b990301a" +checksum = "be8e0d4af7cc08353807aaf80722125a229bf2d67be7fe0b89163c648db3d223" dependencies = [ "darling", - "proc-macro2", "quote", "syn", ] @@ -571,13 +569,13 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ - "async-trait", "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -605,11 +603,10 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" dependencies = [ - "async-trait", "bytes", "futures-util", "http", @@ -626,22 +623,20 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.6" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" dependencies = [ "axum", "axum-core", "bytes", "cookie", - "fastrand", "futures-util", "headers", "http", "http-body", "http-body-util", "mime", - "multer", "pin-project-lite", "serde", "tower", @@ -651,9 +646,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", @@ -861,6 +856,15 @@ dependencies = [ "serde", ] +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1081,6 +1085,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2329,9 +2347,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -2787,13 +2805,14 @@ dependencies = [ [[package]] name = "insta" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513e4067e16e69ed1db5ab56048ed65db32d10ba5fc1217f5393f8f17d8b5a5" +checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" dependencies = [ "console", "linked-hash-map", "once_cell", + "pin-project", "serde", "similar", ] @@ -3143,6 +3162,7 @@ dependencies = [ "dialoguer", "dotenvy", "figment", + "futures-util", "http-body-util", "hyper", "ipnetwork", @@ -3165,6 +3185,7 @@ dependencies = [ "mas-tasks", "mas-templates", "mas-tower", + "oauth2-types", "opentelemetry", "opentelemetry-http", "opentelemetry-jaeger-propagator", @@ -3179,13 +3200,16 @@ dependencies = [ "rand_chacha", "reqwest", "rustls", + "sd-notify", "sentry", "sentry-tower", "sentry-tracing", "serde_json", "serde_yaml", "sqlx", + "syn2mas", "tokio", + "tokio-stream", "tokio-util", "tower", "tower-http", @@ -3505,6 +3529,7 @@ version = "0.13.0" dependencies = [ "anyhow", "async-trait", + "ruma-common", "tokio", ] @@ -3731,9 +3756,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" @@ -3784,9 +3809,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212b4cab3aad057bc6e611814472905546c533295723b9e26a31c7feb19a8e65" +checksum = "cff7b8df5e85e30b87c2b0b3f58ba3a87b68e133738bf512a7713769326dbca9" dependencies = [ "memo-map", "self_cell", @@ -3797,9 +3822,9 @@ dependencies = [ [[package]] name = "minijinja-contrib" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22c4652069ecd6ce1e7724229d859f11f8a0804512da4c274e67d937b833e47c" +checksum = "7ac3e47a9006ed0500425a092c9f8b2e56d10f8aeec8ce870c5e8a7c6ef2d7c3" dependencies = [ "minijinja", "serde", @@ -4622,9 +4647,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" dependencies = [ "unicode-ident", ] @@ -4688,9 +4713,9 @@ checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" [[package]] name = "psl" -version = "2.1.80" +version = "2.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ff66fe75e86ef6bb57a5e7c1af22cc3ff5368ec610559609ea08e304d7c772" +checksum = "5871e872678223987b84739333bf13e42f0c1fb102e30bba8dcdf1340d0bbcc9" dependencies = [ "psl-types", ] @@ -4790,9 +4815,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] @@ -5133,9 +5158,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.21" +version = "0.23.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8" +checksum = "9fb9263ab4eb695e42321db096e3b8fbd715a59b154d5c88d82db2175b681ba7" dependencies = [ "aws-lc-rs", "log", @@ -5171,9 +5196,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" dependencies = [ "web-time", ] @@ -5301,6 +5326,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "sd-notify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4" +dependencies = [ + "libc", +] + [[package]] name = "sea-query" version = "0.32.1" @@ -5391,9 +5425,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "sentry" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +checksum = "3a7332159e544e34db06b251b1eda5e546bd90285c3f58d9c8ff8450b484e0da" dependencies = [ "httpdate", "reqwest", @@ -5408,9 +5442,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +checksum = "565ec31ad37bab8e6d9f289f34913ed8768347b133706192f10606dabd5c6bc4" dependencies = [ "backtrace", "once_cell", @@ -5420,9 +5454,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +checksum = "e860275f25f27e8c0c7726ce116c7d5c928c5bba2ee73306e52b20a752298ea6" dependencies = [ "hostname", "libc", @@ -5434,9 +5468,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +checksum = "653942e6141f16651273159f4b8b1eaeedf37a7554c00cd798953e64b8a9bf72" dependencies = [ "once_cell", "rand", @@ -5447,9 +5481,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +checksum = "105e3a956c8aa9dab1e4087b1657b03271bfc49d838c6ae9bfc7c58c802fd0ef" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5457,9 +5491,9 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c90802b38c899a2c9e557dff25ad186362eddf755d5f5244001b172dd03bead" +checksum = "082f781dfc504d984e16d99f8dbf94d6ee4762dd0fc28de25713d0f900a8164d" dependencies = [ "http", "pin-project", @@ -5471,9 +5505,9 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +checksum = "64e75c831b4d8b34a5aec1f65f67c5d46a26c7c5d3c7abd8b5ef430796900cf8" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5483,9 +5517,9 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.34.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +checksum = "2d4203359e60724aa05cf2385aaf5d4f147e837185d7dd2b9ccf1ee77f4420c8" dependencies = [ "debugid", "hex", @@ -5544,9 +5578,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.137" +version = "1.0.138" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" dependencies = [ "indexmap 2.7.1", "itoa", @@ -5565,19 +5599,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_qs" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" -dependencies = [ - "axum", - "futures", - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "serde_spanned" version = "0.6.8" @@ -6012,6 +6033,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "static_assertions_next" version = "1.1.2" @@ -6065,15 +6092,39 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.90" +version = "2.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn2mas" +version = "0.13.0" +dependencies = [ + "anyhow", + "camino", + "chrono", + "compact_str", + "figment", + "futures-util", + "insta", + "mas-config", + "mas-storage-pg", + "rand", + "serde", + "sqlx", + "thiserror 2.0.11", + "thiserror-ext", + "tokio", + "tracing", + "ulid", + "uuid", +] + [[package]] name = "sync_wrapper" version = "1.0.1" @@ -6140,6 +6191,28 @@ dependencies = [ "thiserror-impl 2.0.11", ] +[[package]] +name = "thiserror-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4323942237f7cc071061f2c5f0db919e6053c2cdf58c6bc974883073429737" +dependencies = [ + "thiserror 1.0.69", + "thiserror-ext-derive", +] + +[[package]] +name = "thiserror-ext-derive" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96541747c50e6c73e094737938f4f5dfaf50c48a31adff4197a3e2a481371674" +dependencies = [ + "either", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror-impl" version = "1.0.69" @@ -6283,9 +6356,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -6592,9 +6665,9 @@ checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" [[package]] name = "unicode-normalization" diff --git a/Cargo.toml b/Cargo.toml index 594f0dd05..26e65aaa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,17 +54,21 @@ mas-tasks = { path = "./crates/tasks/", version = "=0.13.0" } mas-templates = { path = "./crates/templates/", version = "=0.13.0" } mas-tower = { path = "./crates/tower/", version = "=0.13.0" } oauth2-types = { path = "./crates/oauth2-types/", version = "=0.13.0" } +syn2mas = { path = "./crates/syn2mas", version = "=0.13.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] -version = "0.13.5" -features = ["axum", "axum-headers", "macros"] +version = "0.14.0" +features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"] # GraphQL server [workspace.dependencies.async-graphql] version = "7.0.14" features = ["chrono", "url", "tracing"] +[workspace.dependencies.async-stream] +version = "0.3.6" + # Utility to write and implement async traits [workspace.dependencies.async-trait] version = "0.1.85" @@ -75,11 +79,11 @@ version = "1.0.95" # HTTP router [workspace.dependencies.axum] -version = "0.7.9" +version = "0.8.1" # Extra utilities for Axum [workspace.dependencies.axum-extra] -version = "0.9.6" +version = "0.10.0" features = ["cookie-private", "cookie-key-expansion", "typed-header"] # Constant-time base64 @@ -94,6 +98,10 @@ version = "1.9.0" [workspace.dependencies.camino] version = "1.1.9" +# Memory optimisation for short strings +[workspace.dependencies.compact_str] +version = "0.8.1" + # Time utilities [workspace.dependencies.chrono] version = "0.4.39" @@ -145,7 +153,7 @@ version = "0.1.2" # HTTP client and server [workspace.dependencies.hyper] -version = "1.5.2" +version = "1.6.0" features = ["client", "http1", "http2"] # Additional Hyper utilties @@ -169,7 +177,7 @@ default-features = false # Snapshot testing [workspace.dependencies.insta] -version = "1.42.0" +version = "1.42.1" features = ["yaml", "json"] # Email sending @@ -188,12 +196,12 @@ features = [ # Templates [workspace.dependencies.minijinja] -version = "2.6.0" +version = "2.7.0" features = ["loader", "json", "speedups", "unstable_machinery"] # Additional filters for minijinja [workspace.dependencies.minijinja-contrib] -version = "2.6.0" +version = "2.7.0" features = ["pycompat"] # Utilities to deal with non-zero values @@ -240,9 +248,13 @@ version = "0.12.12" default-features = false features = ["http2", "rustls-tls-manual-roots", "charset", "json", "socks"] +# Matrix-related types +[workspace.dependencies.ruma-common] +version = "0.15.0" + # TLS stack [workspace.dependencies.rustls] -version = "0.23.21" +version = "0.23.22" # Use platform-specific verifier for TLS [workspace.dependencies.rustls-platform-verifier] @@ -271,18 +283,18 @@ features = [ # Sentry error tracking [workspace.dependencies.sentry] -version = "0.34.0" +version = "0.36.0" default-features = false features = ["backtrace", "contexts", "panic", "tower", "reqwest"] # Sentry tower layer [workspace.dependencies.sentry-tower] -version = "0.34.0" +version = "0.36.0" features = ["http"] # Sentry tracing integration [workspace.dependencies.sentry-tracing] -version = "0.34.0" +version = "0.36.0" # Serialization and deserialization [workspace.dependencies.serde] @@ -291,7 +303,7 @@ features = ["derive"] # Most of the time, if we need serde, we need derive # JSON serialization and deserialization [workspace.dependencies.serde_json] -version = "1.0.137" +version = "1.0.138" features = ["preserve_order"] # SQL database support @@ -312,11 +324,17 @@ features = [ [workspace.dependencies.thiserror] version = "2.0.11" +[workspace.dependencies.thiserror-ext] +version = "0.2.1" + # Async runtime [workspace.dependencies.tokio] version = "1.43.0" features = ["full"] +[workspace.dependencies.tokio-stream] +version = "0.1.17" + # Useful async utilities [workspace.dependencies.tokio-util] version = "0.7.13" diff --git a/clippy.toml b/clippy.toml index ac0f49bf4..3cbf7c74c 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,4 +1,4 @@ -doc-valid-idents = ["OpenID", "OAuth", "..", "PostgreSQL"] +doc-valid-idents = ["OpenID", "OAuth", "..", "PostgreSQL", "SQLite"] disallowed-methods = [ { path = "rand::thread_rng", reason = "do not create rngs on the fly, pass them as parameters" }, diff --git a/crates/axum-utils/src/client_authorization.rs b/crates/axum-utils/src/client_authorization.rs index 51ce79978..e89114b0e 100644 --- a/crates/axum-utils/src/client_authorization.rs +++ b/crates/axum-utils/src/client_authorization.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -6,7 +6,6 @@ use std::collections::HashMap; -use async_trait::async_trait; use axum::{ extract::{ rejection::{FailedToDeserializeForm, FormRejection}, @@ -321,7 +320,6 @@ impl IntoResponse for ClientAuthorizationError { } } -#[async_trait] impl FromRequest for ClientAuthorization where F: DeserializeOwned, diff --git a/crates/axum-utils/src/cookies.rs b/crates/axum-utils/src/cookies.rs index 1c9e0eb09..c4f1d8d28 100644 --- a/crates/axum-utils/src/cookies.rs +++ b/crates/axum-utils/src/cookies.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -8,7 +8,6 @@ use std::convert::Infallible; -use async_trait::async_trait; use axum::{ extract::{FromRef, FromRequestParts}, response::{IntoResponseParts, ResponseParts}, @@ -65,7 +64,6 @@ impl CookieManager { } } -#[async_trait] impl FromRequestParts for CookieJar where CookieManager: FromRef, diff --git a/crates/axum-utils/src/user_authorization.rs b/crates/axum-utils/src/user_authorization.rs index eb71ad5a4..1a1822b82 100644 --- a/crates/axum-utils/src/user_authorization.rs +++ b/crates/axum-utils/src/user_authorization.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -6,7 +6,6 @@ use std::{collections::HashMap, error::Error}; -use async_trait::async_trait; use axum::{ extract::{ rejection::{FailedToDeserializeForm, FormRejection}, @@ -284,7 +283,6 @@ where } } -#[async_trait] impl FromRequest for UserAuthorization where F: DeserializeOwned, diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5c5039d39..7c20c3b28 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -23,6 +23,7 @@ console = "0.15.10" dialoguer = { version = "0.11.0", features = ["fuzzy-select"] } dotenvy = "0.15.7" figment.workspace = true +futures-util.workspace = true http-body-util.workspace = true hyper.workspace = true ipnetwork = "0.20.0" @@ -32,11 +33,13 @@ rand.workspace = true rand_chacha = "0.3.1" reqwest.workspace = true rustls.workspace = true +sd-notify = "0.4.5" serde_json.workspace = true serde_yaml = "0.9.34" sqlx.workspace = true tokio.workspace = true tokio-util.workspace = true +tokio-stream.workspace = true tower.workspace = true tower-http.workspace = true url.workspace = true @@ -78,6 +81,9 @@ mas-tasks.workspace = true mas-templates.workspace = true mas-tower.workspace = true +oauth2-types.workspace = true +syn2mas.workspace = true + [build-dependencies] anyhow.workspace = true vergen-gitcl = { version = "1.0.5", features = ["rustc"] } diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 2ec207b0a..54a6ff74d 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -6,10 +6,7 @@ use std::{convert::Infallible, net::IpAddr, sync::Arc, time::Instant}; -use axum::{ - async_trait, - extract::{FromRef, FromRequestParts}, -}; +use axum::extract::{FromRef, FromRequestParts}; use ipnetwork::IpNetwork; use mas_data_model::SiteConfig; use mas_handlers::{ @@ -28,6 +25,7 @@ use mas_templates::Templates; use opentelemetry::{metrics::Histogram, KeyValue}; use rand::SeedableRng; use sqlx::PgPool; +use tracing::Instrument; use crate::telemetry::METER; @@ -88,29 +86,43 @@ impl AppState { self.conn_acquisition_histogram = Some(histogram); } - /// Init the metadata cache. - /// - /// # Panics - /// - /// Panics if the metadata cache could not be initialized. - pub async fn init_metadata_cache(&self) { - // XXX: this panics because the error is annoying to propagate - let conn = self - .pool - .acquire() - .await - .expect("Failed to acquire a database connection"); + /// Init the metadata cache in the background + pub fn init_metadata_cache(&self) { + let pool = self.pool.clone(); + let metadata_cache = self.metadata_cache.clone(); + let http_client = self.http_client.clone(); - let mut repo = PgRepository::from_conn(conn); + tokio::spawn( + async move { + let conn = match pool.acquire().await { + Ok(conn) => conn, + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "Failed to acquire a database connection" + ); + return; + } + }; - self.metadata_cache - .warm_up_and_run( - &self.http_client, - std::time::Duration::from_secs(60 * 15), - &mut repo, - ) - .await - .expect("Failed to warm up the metadata cache"); + let mut repo = PgRepository::from_conn(conn); + + if let Err(e) = metadata_cache + .warm_up_and_run( + &http_client, + std::time::Duration::from_secs(60 * 15), + &mut repo, + ) + .await + { + tracing::error!( + error = &e as &dyn std::error::Error, + "Failed to warm up the metadata cache" + ); + } + } + .instrument(tracing::info_span!("metadata_cache.background_warmup")), + ); } } @@ -198,7 +210,6 @@ impl FromRef for BoxHomeserverConnection { } } -#[async_trait] impl FromRequestParts for BoxClock { type Rejection = Infallible; @@ -211,7 +222,6 @@ impl FromRequestParts for BoxClock { } } -#[async_trait] impl FromRequestParts for BoxRng { type Rejection = Infallible; @@ -228,7 +238,6 @@ impl FromRequestParts for BoxRng { } } -#[async_trait] impl FromRequestParts for Policy { type Rejection = ErrorWrapper; @@ -241,7 +250,6 @@ impl FromRequestParts for Policy { } } -#[async_trait] impl FromRequestParts for ActivityTracker { type Rejection = Infallible; @@ -300,7 +308,6 @@ fn infer_client_ip( client_ip.or(fallback) } -#[async_trait] impl FromRequestParts for BoundActivityTracker { type Rejection = Infallible; @@ -315,7 +322,6 @@ impl FromRequestParts for BoundActivityTracker { } } -#[async_trait] impl FromRequestParts for RequesterFingerprint { type Rejection = Infallible; @@ -337,7 +343,6 @@ impl FromRequestParts for RequesterFingerprint { } } -#[async_trait] impl FromRequestParts for BoxRepository { type Rejection = ErrorWrapper; diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index b96e309b3..93228db39 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -310,7 +310,7 @@ impl Options { info!( %compat_access_token.id, %compat_session.id, - %compat_session.device, + compat_session.device = compat_session.device.map(tracing::field::display), %user.id, %user.username, "Compatibility token issued: {}", compat_access_token.token diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 18548b332..ea3178e4e 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -19,6 +19,7 @@ mod debug; mod doctor; mod manage; mod server; +mod syn2mas; mod templates; mod worker; @@ -48,6 +49,11 @@ enum Subcommand { /// Run diagnostics on the deployment Doctor(self::doctor::Options), + + /// Migrate from Synapse's built-in auth system to MAS. + #[clap(name = "syn2mas")] + // Box<> is to work around a 'large size difference between variants' lint + Syn2Mas(Box), } #[derive(Parser, Debug)] @@ -75,6 +81,7 @@ impl Options { Some(S::Templates(c)) => Box::pin(c.run(figment)).await, Some(S::Debug(c)) => Box::pin(c.run(figment)).await, Some(S::Doctor(c)) => Box::pin(c.run(figment)).await, + Some(S::Syn2Mas(c)) => Box::pin(c.run(figment)).await, None => Box::pin(self::server::Options::default().run(figment)).await, } } diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 53bfa899e..e56fd087e 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -24,11 +24,11 @@ use tracing::{info, info_span, warn, Instrument}; use crate::{ app_state::AppState, - shutdown::ShutdownManager, + lifecycle::LifecycleManager, util::{ database_pool_from_config, mailer_from_config, password_manager_from_config, - policy_factory_from_config, register_sighup, site_config_from_config, - templates_from_config, + policy_factory_from_config, site_config_from_config, templates_from_config, + test_mailer_in_background, }, }; @@ -57,7 +57,7 @@ impl Options { #[allow(clippy::too_many_lines)] pub async fn run(self, figment: &Figment) -> anyhow::Result { let span = info_span!("cli.run.init").entered(); - let shutdown = ShutdownManager::new()?; + let mut shutdown = LifecycleManager::new()?; let config = AppConfig::extract(figment)?; info!(version = crate::VERSION, "Starting up"); @@ -145,6 +145,7 @@ impl Options { // Load and compile the templates let templates = templates_from_config(&config.templates, &site_config, &url_builder).await?; + shutdown.register_reloadable(&templates); let http_client = mas_http::reqwest_client(); @@ -157,7 +158,7 @@ impl Options { if !self.no_worker { let mailer = mailer_from_config(&config.email, &templates)?; - mailer.test_connection().await?; + test_mailer_in_background(&mailer, Duration::from_secs(30)); info!("Starting task worker"); mas_tasks::init( @@ -186,6 +187,9 @@ impl Options { shutdown.task_tracker(), shutdown.soft_shutdown_token(), ); + + shutdown.register_reloadable(&activity_tracker); + let trusted_proxies = config.http.trusted_proxies.clone(); // Build a rate limiter. @@ -197,9 +201,6 @@ impl Options { // Explicitly the config to properly zeroize secret keys drop(config); - // Listen for SIGHUP - register_sighup(&templates, &activity_tracker)?; - limiter.start(); let graphql_schema = mas_handlers::graphql_schema( @@ -233,8 +234,7 @@ impl Options { conn_acquisition_histogram: None, }; s.init_metrics(); - // XXX: this might panic - s.init_metadata_cache().await; + s.init_metadata_cache(); s }; diff --git a/crates/cli/src/commands/syn2mas.rs b/crates/cli/src/commands/syn2mas.rs new file mode 100644 index 000000000..e9bd199bf --- /dev/null +++ b/crates/cli/src/commands/syn2mas.rs @@ -0,0 +1,248 @@ +use std::{collections::HashMap, process::ExitCode}; + +use anyhow::Context; +use camino::Utf8PathBuf; +use clap::Parser; +use figment::Figment; +use mas_config::{ + ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, SyncConfig, + UpstreamOAuth2Config, +}; +use mas_storage::SystemClock; +use mas_storage_pg::MIGRATOR; +use rand::thread_rng; +use sqlx::{postgres::PgConnectOptions, types::Uuid, Connection, Either, PgConnection}; +use syn2mas::{synapse_config, LockedMasDatabase, MasWriter, SynapseReader}; +use tracing::{error, info_span, warn, Instrument}; + +use crate::util::database_connection_from_config; + +/// The exit code used by `syn2mas check` and `syn2mas migrate` when there are +/// errors preventing migration. +const EXIT_CODE_CHECK_ERRORS: u8 = 10; + +/// The exit code used by `syn2mas check` when there are warnings which should +/// be considered prior to migration. +const EXIT_CODE_CHECK_WARNINGS: u8 = 11; + +#[derive(Parser, Debug)] +pub(super) struct Options { + #[command(subcommand)] + subcommand: Subcommand, + + /// This version of the syn2mas tool is EXPERIMENTAL and INCOMPLETE. It is + /// only suitable for TESTING. If you want to use this tool anyway, + /// please pass this argument. + /// + /// If you want to migrate from Synapse to MAS today, please use the + /// Node.js-based tool in the MAS repository. + #[clap(long = "i-swear-i-am-just-testing-in-a-staging-environment")] + experimental_accepted: bool, + + /// Path to the Synapse configuration (in YAML format). + /// May be specified multiple times if multiple Synapse configuration files + /// are in use. + #[clap(long = "synapse-config")] + synapse_configuration_files: Vec, + + /// Override the Synapse database URI. + /// syn2mas normally loads the Synapse database connection details from the + /// Synapse configuration. However, it may sometimes be necessary to + /// override the database URI and in that case this flag can be used. + /// + /// Should be a connection URI of the following general form: + /// ```text + /// postgresql://[user[:password]@][host][:port][/dbname][?param1=value1&...] + /// ``` + /// To use a UNIX socket at a custom path, the host should be a path to a + /// socket, but in the URI string it must be URI-encoded by replacing + /// `/` with `%2F`. + /// + /// Finally, any missing values will be loaded from the libpq-compatible + /// environment variables `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`, + /// `PGPASSWORD`, etc. It is valid to specify the URL `postgresql:` and + /// configure all values through those environment variables. + #[clap(long = "synapse-database-uri")] + synapse_database_uri: Option, +} + +#[derive(Parser, Debug)] +enum Subcommand { + /// Check the setup for potential problems before running a migration. + /// + /// It is OK for Synapse to be online during these checks. + Check, + /// Perform a migration. Synapse must be offline during this process. + Migrate, +} + +/// The number of parallel writing transactions active against the MAS database. +const NUM_WRITER_CONNECTIONS: usize = 8; + +impl Options { + #[allow(clippy::too_many_lines)] + pub async fn run(self, figment: &Figment) -> anyhow::Result { + warn!("This version of the syn2mas tool is EXPERIMENTAL and INCOMPLETE. Do not use it, except for TESTING."); + if !self.experimental_accepted { + error!("Please agree that you can only use this tool for testing."); + return Ok(ExitCode::FAILURE); + } + + if self.synapse_configuration_files.is_empty() { + error!("Please specify the path to the Synapse configuration file(s)."); + return Ok(ExitCode::FAILURE); + } + + let synapse_config = synapse_config::Config::load(&self.synapse_configuration_files) + .context("Failed to load Synapse configuration")?; + + // Establish a connection to Synapse's Postgres database + let syn_connection_options = if let Some(db_override) = self.synapse_database_uri { + db_override + } else { + synapse_config + .database + .to_sqlx_postgres() + .context("Synapse configuration does not use Postgres, cannot migrate.")? + }; + let mut syn_conn = PgConnection::connect_with(&syn_connection_options) + .await + .context("could not connect to Synapse Postgres database")?; + + let config = DatabaseConfig::extract_or_default(figment)?; + + let mut mas_connection = database_connection_from_config(&config).await?; + + MIGRATOR + .run(&mut mas_connection) + .instrument(info_span!("db.migrate")) + .await + .context("could not run migrations")?; + + if matches!(&self.subcommand, Subcommand::Migrate { .. }) { + // First perform a config sync + // This is crucial to ensure we register upstream OAuth providers + // in the MAS database + // + let config = SyncConfig::extract(figment)?; + let clock = SystemClock::default(); + let encrypter = config.secrets.encrypter(); + + crate::sync::config_sync( + config.upstream_oauth2, + config.clients, + &mut mas_connection, + &encrypter, + &clock, + // Don't prune — we don't want to be unnecessarily destructive + false, + // Not a dry run — we do want to create the providers in the database + false, + ) + .await?; + } + + let Either::Left(mut mas_connection) = LockedMasDatabase::try_new(&mut mas_connection) + .await + .context("failed to issue query to lock database")? + else { + error!("Failed to acquire syn2mas lock on the database."); + error!("This likely means that another syn2mas instance is already running!"); + return Ok(ExitCode::FAILURE); + }; + + // Check configuration + let (mut check_warnings, mut check_errors) = syn2mas::synapse_config_check(&synapse_config); + { + let (extra_warnings, extra_errors) = + syn2mas::synapse_config_check_against_mas_config(&synapse_config, figment).await?; + check_warnings.extend(extra_warnings); + check_errors.extend(extra_errors); + } + + // Check databases + syn2mas::mas_pre_migration_checks(&mut mas_connection).await?; + { + let (extra_warnings, extra_errors) = + syn2mas::synapse_database_check(&mut syn_conn, &synapse_config, figment).await?; + check_warnings.extend(extra_warnings); + check_errors.extend(extra_errors); + } + + // Display errors and warnings + if !check_errors.is_empty() { + eprintln!("===== Errors ====="); + eprintln!("These issues prevent migrating from Synapse to MAS right now:\n"); + for error in &check_errors { + eprintln!("• {error}\n"); + } + } + if !check_warnings.is_empty() { + eprintln!("===== Warnings ====="); + eprintln!("These potential issues should be considered before migrating from Synapse to MAS right now:\n"); + for warning in &check_warnings { + eprintln!("• {warning}\n"); + } + } + + // Do not proceed if there are any errors + if !check_errors.is_empty() { + return Ok(ExitCode::from(EXIT_CODE_CHECK_ERRORS)); + } + + match self.subcommand { + Subcommand::Check => { + if !check_warnings.is_empty() { + return Ok(ExitCode::from(EXIT_CODE_CHECK_WARNINGS)); + } + + println!("Check completed successfully with no errors or warnings."); + + Ok(ExitCode::SUCCESS) + } + Subcommand::Migrate => { + let provider_id_mappings: HashMap = { + let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment)?; + + mas_oauth2 + .providers + .iter() + .filter_map(|provider| { + let synapse_idp_id = provider.synapse_idp_id.clone()?; + Some((synapse_idp_id, Uuid::from(provider.id))) + }) + .collect() + }; + + // TODO how should we handle warnings at this stage? + + let mut reader = SynapseReader::new(&mut syn_conn, true).await?; + let mut writer_mas_connections = Vec::with_capacity(NUM_WRITER_CONNECTIONS); + for _ in 0..NUM_WRITER_CONNECTIONS { + writer_mas_connections.push(database_connection_from_config(&config).await?); + } + let mut writer = MasWriter::new(mas_connection, writer_mas_connections).await?; + + // TODO is this rng ok? + #[allow(clippy::disallowed_methods)] + let mut rng = thread_rng(); + + // TODO progress reporting + let mas_matrix = MatrixConfig::extract(figment)?; + syn2mas::migrate( + &mut reader, + &mut writer, + &mas_matrix.homeserver, + &mut rng, + &provider_id_mappings, + ) + .await?; + + reader.finish().await?; + writer.finish().await?; + + Ok(ExitCode::SUCCESS) + } + } + } +} diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 3e3df8402..8d2b4cd33 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use std::process::ExitCode; +use std::{process::ExitCode, time::Duration}; use clap::Parser; use figment::Figment; @@ -14,10 +14,10 @@ use mas_router::UrlBuilder; use tracing::{info, info_span}; use crate::{ - shutdown::ShutdownManager, + lifecycle::LifecycleManager, util::{ database_pool_from_config, mailer_from_config, site_config_from_config, - templates_from_config, + templates_from_config, test_mailer_in_background, }, }; @@ -26,7 +26,7 @@ pub(super) struct Options {} impl Options { pub async fn run(self, figment: &Figment) -> anyhow::Result { - let shutdown = ShutdownManager::new()?; + let shutdown = LifecycleManager::new()?; let span = info_span!("cli.worker.init").entered(); let config = AppConfig::extract(figment)?; @@ -55,7 +55,7 @@ impl Options { templates_from_config(&config.templates, &site_config, &url_builder).await?; let mailer = mailer_from_config(&config.email, &templates)?; - mailer.test_connection().await?; + test_mailer_in_background(&mailer, Duration::from_secs(30)); let http_client = mas_http::reqwest_client(); let conn = SynapseConnection::new( diff --git a/crates/cli/src/lifecycle.rs b/crates/cli/src/lifecycle.rs new file mode 100644 index 000000000..aa79d2114 --- /dev/null +++ b/crates/cli/src/lifecycle.rs @@ -0,0 +1,239 @@ +// Copyright 2024, 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::{future::Future, process::ExitCode, time::Duration}; + +use futures_util::future::{BoxFuture, Either}; +use mas_handlers::ActivityTracker; +use mas_templates::Templates; +use tokio::signal::unix::{Signal, SignalKind}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +/// A helper to manage the lifecycle of the service, inclusing handling graceful +/// shutdowns and configuration reloads. +/// +/// It will listen for SIGTERM and SIGINT signals, and will trigger a soft +/// shutdown on the first signal, and a hard shutdown on the second signal or +/// after a timeout. +/// +/// Users of this manager should use the `soft_shutdown_token` to react to a +/// soft shutdown, which should gracefully finish requests and close +/// connections, and the `hard_shutdown_token` to react to a hard shutdown, +/// which should drop all connections and finish all requests. +/// +/// They should also use the `task_tracker` to make it track things running, so +/// that it knows when the soft shutdown is over and worked. +/// +/// It also integrates with [`sd_notify`] to notify the service manager of the +/// state of the service. +pub struct LifecycleManager { + hard_shutdown_token: CancellationToken, + soft_shutdown_token: CancellationToken, + task_tracker: TaskTracker, + sigterm: Signal, + sigint: Signal, + sighup: Signal, + timeout: Duration, + reload_handlers: Vec BoxFuture<'static, ()>>>, +} + +/// Represents a thing that can be reloaded with a SIGHUP +pub trait Reloadable: Clone + Send { + fn reload(&self) -> impl Future + Send; +} + +impl Reloadable for ActivityTracker { + async fn reload(&self) { + self.flush().await; + } +} + +impl Reloadable for Templates { + async fn reload(&self) { + if let Err(err) = self.reload().await { + tracing::error!( + error = &err as &dyn std::error::Error, + "Failed to reload templates" + ); + } + } +} + +/// A wrapper around [`sd_notify::notify`] that logs any errors +fn notify(states: &[sd_notify::NotifyState]) { + if let Err(e) = sd_notify::notify(false, states) { + tracing::error!( + error = &e as &dyn std::error::Error, + "Failed to notify service manager" + ); + } +} + +impl LifecycleManager { + /// Create a new shutdown manager, installing the signal handlers + /// + /// # Errors + /// + /// Returns an error if the signal handler could not be installed + pub fn new() -> Result { + let hard_shutdown_token = CancellationToken::new(); + let soft_shutdown_token = hard_shutdown_token.child_token(); + let sigterm = tokio::signal::unix::signal(SignalKind::terminate())?; + let sigint = tokio::signal::unix::signal(SignalKind::interrupt())?; + let sighup = tokio::signal::unix::signal(SignalKind::hangup())?; + let timeout = Duration::from_secs(60); + let task_tracker = TaskTracker::new(); + + notify(&[sd_notify::NotifyState::MainPid(std::process::id())]); + + Ok(Self { + hard_shutdown_token, + soft_shutdown_token, + task_tracker, + sigterm, + sigint, + sighup, + timeout, + reload_handlers: Vec::new(), + }) + } + + /// Add a handler to be called when the server gets a SIGHUP + pub fn register_reloadable(&mut self, reloadable: &(impl Reloadable + 'static)) { + let reloadable = reloadable.clone(); + self.reload_handlers.push(Box::new(move || { + let reloadable = reloadable.clone(); + Box::pin(async move { reloadable.reload().await }) + })); + } + + /// Get a reference to the task tracker + #[must_use] + pub fn task_tracker(&self) -> &TaskTracker { + &self.task_tracker + } + + /// Get a cancellation token that can be used to react to a hard shutdown + #[must_use] + pub fn hard_shutdown_token(&self) -> CancellationToken { + self.hard_shutdown_token.clone() + } + + /// Get a cancellation token that can be used to react to a soft shutdown + #[must_use] + pub fn soft_shutdown_token(&self) -> CancellationToken { + self.soft_shutdown_token.clone() + } + + /// Run until we finish completely shutting down. + pub async fn run(mut self) -> ExitCode { + notify(&[sd_notify::NotifyState::Ready]); + + // This will be `Some` if we have the watchdog enabled, and `None` if not + let mut watchdog_interval = { + let mut watchdog_usec = 0; + if sd_notify::watchdog_enabled(false, &mut watchdog_usec) { + Some(tokio::time::interval(Duration::from_micros( + watchdog_usec / 2, + ))) + } else { + None + } + }; + + // Wait for a first shutdown signal and trigger the soft shutdown + let likely_crashed = loop { + // This makes a Future that will either yield the watchdog tick if enabled, or a + // pending Future if not + let watchdog_tick = if let Some(watchdog_interval) = &mut watchdog_interval { + Either::Left(watchdog_interval.tick()) + } else { + Either::Right(futures_util::future::pending()) + }; + + tokio::select! { + () = self.soft_shutdown_token.cancelled() => { + tracing::warn!("Another task triggered a shutdown, it likely crashed! Shutting down"); + break true; + }, + + _ = self.sigterm.recv() => { + tracing::info!("Shutdown signal received (SIGTERM), shutting down"); + break false; + }, + + _ = self.sigint.recv() => { + tracing::info!("Shutdown signal received (SIGINT), shutting down"); + break false; + }, + + _ = watchdog_tick => { + notify(&[ + sd_notify::NotifyState::Watchdog, + ]); + }, + + _ = self.sighup.recv() => { + tracing::info!("Reload signal received (SIGHUP), reloading"); + + notify(&[ + sd_notify::NotifyState::Reloading, + sd_notify::NotifyState::monotonic_usec_now() + .expect("Failed to read monotonic clock") + ]); + + // XXX: if one handler takes a long time, it will block the + // rest of the shutdown process, which is not ideal. We + // should probably have a timeout here + futures_util::future::join_all( + self.reload_handlers + .iter() + .map(|handler| handler()) + ).await; + + notify(&[sd_notify::NotifyState::Ready]); + + tracing::info!("Reloading done"); + }, + } + }; + + notify(&[sd_notify::NotifyState::Stopping]); + + self.soft_shutdown_token.cancel(); + self.task_tracker.close(); + + // Start the timeout + let timeout = tokio::time::sleep(self.timeout); + tokio::select! { + _ = self.sigterm.recv() => { + tracing::warn!("Second shutdown signal received (SIGTERM), abort"); + }, + _ = self.sigint.recv() => { + tracing::warn!("Second shutdown signal received (SIGINT), abort"); + }, + () = timeout => { + tracing::warn!("Shutdown timeout reached, abort"); + }, + () = self.task_tracker.wait() => { + // This is the "happy path", we have gracefully shutdown + }, + } + + self.hard_shutdown_token().cancel(); + + // TODO: we may want to have a time out on the task tracker, in case we have + // really stuck tasks on it + self.task_tracker().wait().await; + + tracing::info!("All tasks are done, exitting"); + + if likely_crashed { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f487853bc..049a2fdc0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -18,8 +18,8 @@ use tracing_subscriber::{ mod app_state; mod commands; +mod lifecycle; mod server; -mod shutdown; mod sync; mod telemetry; mod util; diff --git a/crates/cli/src/shutdown.rs b/crates/cli/src/shutdown.rs deleted file mode 100644 index 5386166af..000000000 --- a/crates/cli/src/shutdown.rs +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -use std::{process::ExitCode, time::Duration}; - -use tokio::signal::unix::{Signal, SignalKind}; -use tokio_util::{sync::CancellationToken, task::TaskTracker}; - -/// A helper to manage graceful shutdowns and track tasks that gracefully -/// shutdown. -/// -/// It will listen for SIGTERM and SIGINT signals, and will trigger a soft -/// shutdown on the first signal, and a hard shutdown on the second signal or -/// after a timeout. -/// -/// Users of this manager should use the `soft_shutdown_token` to react to a -/// soft shutdown, which should gracefully finish requests and close -/// connections, and the `hard_shutdown_token` to react to a hard shutdown, -/// which should drop all connections and finish all requests. -/// -/// They should also use the `task_tracker` to make it track things running, so -/// that it knows when the soft shutdown is over and worked. -pub struct ShutdownManager { - hard_shutdown_token: CancellationToken, - soft_shutdown_token: CancellationToken, - task_tracker: TaskTracker, - sigterm: Signal, - sigint: Signal, - timeout: Duration, -} - -impl ShutdownManager { - /// Create a new shutdown manager, installing the signal handlers - /// - /// # Errors - /// - /// Returns an error if the signal handler could not be installed - pub fn new() -> Result { - let hard_shutdown_token = CancellationToken::new(); - let soft_shutdown_token = hard_shutdown_token.child_token(); - let sigterm = tokio::signal::unix::signal(SignalKind::terminate())?; - let sigint = tokio::signal::unix::signal(SignalKind::interrupt())?; - let timeout = Duration::from_secs(60); - let task_tracker = TaskTracker::new(); - - Ok(Self { - hard_shutdown_token, - soft_shutdown_token, - task_tracker, - sigterm, - sigint, - timeout, - }) - } - - /// Get a reference to the task tracker - #[must_use] - pub fn task_tracker(&self) -> &TaskTracker { - &self.task_tracker - } - - /// Get a cancellation token that can be used to react to a hard shutdown - #[must_use] - pub fn hard_shutdown_token(&self) -> CancellationToken { - self.hard_shutdown_token.clone() - } - - /// Get a cancellation token that can be used to react to a soft shutdown - #[must_use] - pub fn soft_shutdown_token(&self) -> CancellationToken { - self.soft_shutdown_token.clone() - } - - /// Run until we finish completely shutting down. - pub async fn run(mut self) -> ExitCode { - // Wait for a first signal and trigger the soft shutdown - let likely_crashed = tokio::select! { - () = self.soft_shutdown_token.cancelled() => { - tracing::warn!("Another task triggered a shutdown, it likely crashed! Shutting down"); - true - }, - - _ = self.sigterm.recv() => { - tracing::info!("Shutdown signal received (SIGTERM), shutting down"); - false - }, - - _ = self.sigint.recv() => { - tracing::info!("Shutdown signal received (SIGINT), shutting down"); - false - }, - }; - - self.soft_shutdown_token.cancel(); - self.task_tracker.close(); - - // Start the timeout - let timeout = tokio::time::sleep(self.timeout); - tokio::select! { - _ = self.sigterm.recv() => { - tracing::warn!("Second shutdown signal received (SIGTERM), abort"); - }, - _ = self.sigint.recv() => { - tracing::warn!("Second shutdown signal received (SIGINT), abort"); - }, - () = timeout => { - tracing::warn!("Shutdown timeout reached, abort"); - }, - () = self.task_tracker.wait() => { - // This is the "happy path", we have gracefully shutdown - }, - } - - self.hard_shutdown_token().cancel(); - - // TODO: we may want to have a time out on the task tracker, in case we have - // really stuck tasks on it - self.task_tracker().wait().await; - - tracing::info!("All tasks are done, exitting"); - - if likely_crashed { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } - } -} diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 3d3d8f676..02a03b0dc 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -14,7 +14,7 @@ use mas_config::{ }; use mas_data_model::SiteConfig; use mas_email::{MailTransport, Mailer}; -use mas_handlers::{passwords::PasswordManager, ActivityTracker}; +use mas_handlers::passwords::PasswordManager; use mas_policy::PolicyFactory; use mas_router::UrlBuilder; use mas_templates::{SiteConfigExt, TemplateLoadingError, Templates}; @@ -22,7 +22,7 @@ use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, ConnectOptions, PgConnection, PgPool, }; -use tracing::{error, info, log::LevelFilter}; +use tracing::{log::LevelFilter, Instrument}; pub async fn password_manager_from_config( config: &PasswordsConfig, @@ -99,6 +99,27 @@ pub fn mailer_from_config( Ok(Mailer::new(templates.clone(), transport, from, reply_to)) } +/// Test the connection to the mailer in a background task +pub fn test_mailer_in_background(mailer: &Mailer, timeout: Duration) { + let mailer = mailer.clone(); + + let span = tracing::info_span!("cli.test_mailer"); + tokio::spawn(async move { + match tokio::time::timeout(timeout, mailer.test_connection()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + error = &err as &dyn std::error::Error, + "Could not connect to the mail backend, tasks sending mails may fail!" + ); + } + Err(_) => { + tracing::warn!("Timed out while testing the mail backend connection, tasks sending mails may fail!"); + } + } + }.instrument(span)); +} + pub async fn policy_factory_from_config( config: &PolicyConfig, matrix_config: &MatrixConfig, @@ -313,37 +334,6 @@ pub async fn database_connection_from_config( .context("could not connect to the database") } -/// Reload templates on SIGHUP -pub fn register_sighup( - templates: &Templates, - activity_tracker: &ActivityTracker, -) -> anyhow::Result<()> { - #[cfg(unix)] - { - let mut signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?; - let templates = templates.clone(); - let activity_tracker = activity_tracker.clone(); - - tokio::spawn(async move { - loop { - if signal.recv().await.is_none() { - // No more signals will be received, breaking - break; - }; - - info!("SIGHUP received, reloading templates & flushing activity tracker"); - - activity_tracker.flush().await; - templates.clone().reload().await.unwrap_or_else(|err| { - error!(?err, "Error while reloading templates"); - }); - } - }); - } - - Ok(()) -} - #[cfg(test)] mod tests { use rand::SeedableRng; diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 4a89fdd89..db2a6b6bb 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -30,7 +30,7 @@ serde_with = { version = "3.12.0", features = ["hex", "chrono"] } serde_json.workspace = true pem-rfc7468 = "0.7.0" -rustls-pki-types = "1.10.1" +rustls-pki-types = "1.11.0" rustls-pemfile = "2.2.0" rand.workspace = true rand_chacha = "0.3.1" diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index df95ee820..aa773e70b 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -51,7 +51,7 @@ pub use self::{ ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode, EmailImportPreference as UpstreamOAuth2EmailImportPreference, ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod, - ResponseMode as UpstreamOAuth2ResponseMode, + Provider as UpstreamOAuth2Provider, ResponseMode as UpstreamOAuth2ResponseMode, TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config, }, }; diff --git a/crates/config/src/sections/passwords.rs b/crates/config/src/sections/passwords.rs index 01da7a694..455dbfd61 100644 --- a/crates/config/src/sections/passwords.rs +++ b/crates/config/src/sections/passwords.rs @@ -179,7 +179,7 @@ fn default_bcrypt_cost() -> Option { } /// A hashing algorithm -#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Algorithm { /// bcrypt diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 1801aa1f2..b76e8ddb2 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -391,6 +391,7 @@ pub struct SignInWithApple { pub key_id: String, } +/// Configuration for one upstream OAuth 2 provider. #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Provider { @@ -537,4 +538,21 @@ pub struct Provider { /// Orders of the keys are not preserved. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub additional_authorization_parameters: BTreeMap, + + /// The ID of the provider that was used by Synapse. + /// In order to perform a Synapse-to-MAS migration, this must be specified. + /// + /// ## For providers that used OAuth 2.0 or OpenID Connect in Synapse + /// + /// ### For `oidc_providers`: + /// This should be specified as `oidc-` followed by the ID that was + /// configured as `idp_id` in one of the `oidc_providers` in the Synapse + /// configuration. + /// For example, if Synapse's configuration contained `idp_id: wombat` for + /// this provider, then specify `oidc-wombat` here. + /// + /// ### For `oidc_config` (legacy): + /// Specify `oidc` here. + #[serde(skip_serializing_if = "Option::is_none")] + pub synapse_idp_id: Option, } diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml index ee49a6c05..845972c19 100644 --- a/crates/data-model/Cargo.toml +++ b/crates/data-model/Cargo.toml @@ -24,7 +24,7 @@ rand.workspace = true rand_chacha = "0.3.1" regex = "1.11.1" woothee = "0.13.0" -ruma-common = "0.15.0" +ruma-common.workspace = true mas-iana.workspace = true mas-jose.workspace = true diff --git a/crates/data-model/src/compat/session.rs b/crates/data-model/src/compat/session.rs index f3d311a5a..e07c0fb7d 100644 --- a/crates/data-model/src/compat/session.rs +++ b/crates/data-model/src/compat/session.rs @@ -71,7 +71,7 @@ pub struct CompatSession { pub id: Ulid, pub state: CompatSessionState, pub user_id: Ulid, - pub device: Device, + pub device: Option, pub user_session_id: Option, pub created_at: DateTime, pub is_synapse_admin: bool, diff --git a/crates/data-model/src/oauth2/authorization_grant.rs b/crates/data-model/src/oauth2/authorization_grant.rs index d34a431c7..2eb616a47 100644 --- a/crates/data-model/src/oauth2/authorization_grant.rs +++ b/crates/data-model/src/oauth2/authorization_grant.rs @@ -17,7 +17,7 @@ use rand::{ distributions::{Alphanumeric, DistString}, RngCore, }; -use ruma_common::{OwnedUserId, UserId}; +use ruma_common::UserId; use serde::Serialize; use ulid::Ulid; use url::Url; @@ -142,8 +142,8 @@ impl AuthorizationGrantStage { } } -pub enum LoginHint { - MXID(OwnedUserId), +pub enum LoginHint<'a> { + MXID(&'a UserId), None, } @@ -200,7 +200,7 @@ impl AuthorizationGrant { match prefix { "mxid" => { // Instead of erroring just return none - let Ok(mxid) = UserId::parse(value) else { + let Ok(mxid) = <&UserId>::try_from(value) else { return LoginHint::None; }; diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 898eb0c7c..388431faf 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -33,7 +33,7 @@ hyper.workspace = true tower.workspace = true tower-http.workspace = true axum.workspace = true -axum-macros = "0.4.2" +axum-macros = "0.5.0" axum-extra.workspace = true rustls.workspace = true @@ -75,7 +75,7 @@ elliptic-curve.workspace = true governor.workspace = true indexmap = "2.7.1" pkcs8.workspace = true -psl = "2.1.80" +psl = "2.1.81" time = "0.3.37" url.workspace = true mime = "0.3.17" diff --git a/crates/handlers/src/admin/call_context.rs b/crates/handlers/src/admin/call_context.rs index a2c07bfcb..30a3bb2ea 100644 --- a/crates/handlers/src/admin/call_context.rs +++ b/crates/handlers/src/admin/call_context.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -107,7 +107,6 @@ pub struct CallContext { pub session: Session, } -#[async_trait::async_trait] impl FromRequestParts for CallContext where S: Send + Sync, diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 56b8ed11a..dca3e0c2d 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -47,7 +47,7 @@ where Templates: FromRef, UrlBuilder: FromRef, { - aide::gen::in_context(|ctx| { + aide::generate::in_context(|ctx| { ctx.schema = schemars::gen::SchemaGenerator::new(schemars::gen::SchemaSettings::openapi3()); }); diff --git a/crates/handlers/src/admin/params.rs b/crates/handlers/src/admin/params.rs index 0c8cab0b9..0d0473819 100644 --- a/crates/handlers/src/admin/params.rs +++ b/crates/handlers/src/admin/params.rs @@ -10,7 +10,6 @@ use std::num::NonZeroUsize; use aide::OperationIo; -use async_trait::async_trait; use axum::{ extract::{ rejection::{PathRejection, QueryRejection}, @@ -110,7 +109,6 @@ impl IntoResponse for PaginationRejection { #[aide(input_with = "Query")] pub struct Pagination(pub mas_storage::Pagination); -#[async_trait] impl FromRequestParts for Pagination { type Rejection = PaginationRejection; diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 2a497ce8c..73060f825 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -32,7 +32,7 @@ where get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc), ) .api_route( - "/oauth2-sessions/:id", + "/oauth2-sessions/{id}", get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc), ) .api_route( @@ -41,31 +41,31 @@ where .post_with(self::users::add, self::users::add_doc), ) .api_route( - "/users/:id", + "/users/{id}", get_with(self::users::get, self::users::get_doc), ) .api_route( - "/users/:id/set-password", + "/users/{id}/set-password", post_with(self::users::set_password, self::users::set_password_doc), ) .api_route( - "/users/by-username/:username", + "/users/by-username/{username}", get_with(self::users::by_username, self::users::by_username_doc), ) .api_route( - "/users/:id/set-admin", + "/users/{id}/set-admin", post_with(self::users::set_admin, self::users::set_admin_doc), ) .api_route( - "/users/:id/deactivate", + "/users/{id}/deactivate", post_with(self::users::deactivate, self::users::deactivate_doc), ) .api_route( - "/users/:id/lock", + "/users/{id}/lock", post_with(self::users::lock, self::users::lock_doc), ) .api_route( - "/users/:id/unlock", + "/users/{id}/unlock", post_with(self::users::unlock, self::users::unlock_doc), ) } diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 5d7a32147..993719564 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -26,7 +26,6 @@ struct DummyState; macro_rules! impl_from_request_parts { ($type:ty) => { - #[axum::async_trait] impl axum::extract::FromRequestParts for $type { type Rejection = std::convert::Infallible; diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 4c92f6226..75834f9b3 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -130,7 +130,7 @@ pub enum Identifier { #[derive(Debug, Serialize, Deserialize)] pub struct ResponseBody { access_token: String, - device_id: Device, + device_id: Option, user_id: String, refresh_token: Option, #[serde_as(as = "Option>")] @@ -386,10 +386,13 @@ async fn user_password_login( username: String, password: String, ) -> Result<(CompatSession, User), RouteError> { + // Try getting the localpart out of the MXID + let username = homeserver.localpart(&username).unwrap_or(&username); + // Find the user let user = repo .user() - .find_by_username(&username) + .find_by_username(username) .await? .filter(mas_data_model::User::is_valid) .ok_or(RouteError::UserNotFound)?; @@ -539,23 +542,25 @@ mod tests { assert_eq!(body["errcode"], "M_UNRECOGNIZED"); } - /// Test that a user can login with a password using the Matrix - /// compatibility API. - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_user_password_login(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - - // Let's provision a user and add a password to it. This part is hard to test - // with just HTTP requests, so we'll use the repository directly. + async fn user_with_password(state: &TestState, username: &str, password: &str) { + let mut rng = state.rng(); let mut repo = state.repository().await.unwrap(); let user = repo .user() - .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .add(&mut rng, &state.clock, username.to_owned()) + .await + .unwrap(); + let (version, hash) = state + .password_manager + .hash(&mut rng, Zeroizing::new(password.as_bytes().to_vec())) .await .unwrap(); + repo.user_password() + .add(&mut rng, &state.clock, &user, version, hash, None) + .await + .unwrap(); let mxid = state.homeserver_connection.mxid(&user.username); state .homeserver_connection @@ -563,28 +568,17 @@ mod tests { .await .unwrap(); - let (version, hashed_password) = state - .password_manager - .hash( - &mut state.rng(), - Zeroizing::new("password".to_owned().into_bytes()), - ) - .await - .unwrap(); - - repo.user_password() - .add( - &mut state.rng(), - &state.clock, - &user, - version, - hashed_password, - None, - ) - .await - .unwrap(); - repo.save().await.unwrap(); + } + + /// Test that a user can login with a password using the Matrix + /// compatibility API. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_password_login(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + user_with_password(&state, "alice", "password").await; // Now let's try to login with the password, without asking for a refresh token. let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ @@ -601,7 +595,7 @@ mod tests { let body: ResponseBody = response.json(); assert!(!body.access_token.is_empty()); - assert_eq!(body.device_id.as_str().len(), 10); + assert_eq!(body.device_id.as_ref().unwrap().as_str().len(), 10); assert_eq!(body.user_id, "@alice:example.com"); assert_eq!(body.refresh_token, None); assert_eq!(body.expires_in_ms, None); @@ -622,7 +616,7 @@ mod tests { let body: ResponseBody = response.json(); assert!(!body.access_token.is_empty()); - assert_eq!(body.device_id.as_str().len(), 10); + assert_eq!(body.device_id.as_ref().unwrap().as_str().len(), 10); assert_eq!(body.user_id, "@alice:example.com"); assert!(body.refresh_token.is_some()); assert!(body.expires_in_ms.is_some()); @@ -662,6 +656,50 @@ mod tests { assert_eq!(body, old_body); } + /// Test that a user can login with a password using the Matrix + /// compatibility API, using a MXID as identifier + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_password_login_mxid(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + user_with_password(&state, "alice", "password").await; + + // Login with a full MXID as identifier + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "@alice:example.com", + }, + "password": "password", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: ResponseBody = response.json(); + assert!(!body.access_token.is_empty()); + assert_eq!(body.device_id.as_ref().unwrap().as_str().len(), 10); + assert_eq!(body.user_id, "@alice:example.com"); + assert_eq!(body.refresh_token, None); + assert_eq!(body.expires_in_ms, None); + + // With a MXID, but with the wrong server name + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "@alice:something.corp", + }, + "password": "password", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + assert_eq!(body["errcode"], "M_FORBIDDEN"); + } + /// Test that password logins are rate limited. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_password_login_rate_limit(pool: PgPool) { @@ -776,7 +814,7 @@ mod tests { let body: ResponseBody = response.json(); assert!(!body.access_token.is_empty()); - assert_eq!(body.device_id, device); + assert_eq!(body.device_id, Some(device)); assert_eq!(body.user_id, "@alice:example.com"); assert_eq!(body.refresh_token, None); assert_eq!(body.expires_in_ms, None); diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index cb9965f98..3fbf30166 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -14,7 +14,6 @@ use async_graphql::{ EmptySubscription, InputObject, }; use axum::{ - async_trait, body::Body, extract::{RawQuery, State as AxumState}, http::StatusCode, @@ -78,7 +77,7 @@ struct GraphQLState { limiter: Limiter, } -#[async_trait] +#[async_trait::async_trait] impl state::State for GraphQLState { async fn repository(&self) -> Result { let repo = PgRepository::from_pool(&self.pool) diff --git a/crates/handlers/src/graphql/model/compat_sessions.rs b/crates/handlers/src/graphql/model/compat_sessions.rs index ec67b7030..cc5269416 100644 --- a/crates/handlers/src/graphql/model/compat_sessions.rs +++ b/crates/handlers/src/graphql/model/compat_sessions.rs @@ -7,6 +7,7 @@ use anyhow::Context as _; use async_graphql::{Context, Description, Enum, Object, ID}; use chrono::{DateTime, Utc}; +use mas_data_model::Device; use mas_storage::{compat::CompatSessionRepository, user::UserRepository}; use url::Url; @@ -81,8 +82,8 @@ impl CompatSession { } /// The Matrix Device ID of this session. - async fn device_id(&self) -> &str { - self.session.device.as_str() + async fn device_id(&self) -> Option<&str> { + self.session.device.as_ref().map(Device::as_str) } /// When the object was created. diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 421802606..6025f3d1e 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -7,10 +7,9 @@ use anyhow::Context; use axum::{ extract::{Path, State}, - response::{IntoResponse, Response}, + response::{Html, IntoResponse, Response}, Form, }; -use axum_extra::response::Html; use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index db19bb18e..7d8ffc5eb 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -6,9 +6,8 @@ use axum::{ extract::{Query, State}, - response::IntoResponse, + response::{Html, IntoResponse}, }; -use axum_extra::response::Html; use mas_axum_utils::{cookies::CookieJar, FancyError}; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository}; @@ -21,7 +20,8 @@ use crate::PreferredLanguage; #[derive(Serialize, Deserialize)] pub struct Params { - code: String, + #[serde(default)] + code: Option, } #[tracing::instrument(name = "handlers.oauth2.device.link.get", skip_all, err)] @@ -32,17 +32,14 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, cookie_jar: CookieJar, - query: Option>, + Query(query): Query, ) -> Result { - let mut form_state = FormState::default(); + let mut form_state = FormState::from_form(&query); // If we have a code in query, find it in the database - if let Some(Query(params)) = query { - // Save the form state so that we echo back the code - form_state = FormState::from_form(¶ms); - + if let Some(code) = &query.code { // Find the code in the database - let code = params.code.to_uppercase(); + let code = code.to_uppercase(); let grant = repo .oauth2_device_code_grant() .find_by_user_code(&code) diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index fcb0a205f..0b187278f 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -10,7 +10,7 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, sentry::SentryEventID, }; -use mas_data_model::{TokenFormatError, TokenType}; +use mas_data_model::{Device, TokenFormatError, TokenType}; use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint}; use mas_keystore::Encrypter; use mas_storage::{ @@ -364,11 +364,12 @@ pub(crate) async fn post( } // Grant the synapse admin scope if the session has the admin flag set. - let synapse_admin = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); - let device_scope = session.device.to_scope_token(); - let scope = [API_SCOPE, device_scope] + let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); + let device_scope_opt = session.device.as_ref().map(Device::to_scope_token); + let scope = [API_SCOPE] .into_iter() - .chain(synapse_admin) + .chain(device_scope_opt) + .chain(synapse_admin_scope_opt) .collect(); activity_tracker @@ -423,11 +424,12 @@ pub(crate) async fn post( } // Grant the synapse admin scope if the session has the admin flag set. - let synapse_admin = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); - let device_scope = session.device.to_scope_token(); - let scope = [API_SCOPE, device_scope] + let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); + let device_scope_opt = session.device.as_ref().map(Device::to_scope_token); + let scope = [API_SCOPE] .into_iter() - .chain(synapse_admin) + .chain(device_scope_opt) + .chain(synapse_admin_scope_opt) .collect(); activity_tracker diff --git a/crates/handlers/src/preferred_language.rs b/crates/handlers/src/preferred_language.rs index afee0a70e..e107e754d 100644 --- a/crates/handlers/src/preferred_language.rs +++ b/crates/handlers/src/preferred_language.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -7,17 +7,15 @@ use std::{convert::Infallible, sync::Arc}; use axum::{ - async_trait, extract::{FromRef, FromRequestParts}, http::request::Parts, }; -use axum_extra::typed_header::TypedHeader; +use headers::HeaderMapExt as _; use mas_axum_utils::language_detection::AcceptLanguage; use mas_i18n::{locale, DataLocale, Translator}; pub struct PreferredLanguage(pub DataLocale); -#[async_trait] impl FromRequestParts for PreferredLanguage where S: Send + Sync, @@ -27,12 +25,11 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let translator: Arc = FromRef::from_ref(state); - let accept_language: Option> = - FromRequestParts::from_request_parts(parts, state).await?; + let accept_language = parts.headers.typed_get::(); let iter = accept_language .iter() - .flat_map(|TypedHeader(accept_language)| accept_language.iter()) + .flat_map(AcceptLanguage::iter) .flat_map(|lang| { let lang = DataLocale::from(lang); // XXX: this is hacky as we may want to actually maintain proper language diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 2ba58414d..4e69ab5df 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -11,7 +11,6 @@ use std::{ }; use axum::{ - async_trait, body::{Bytes, HttpBody}, extract::{FromRef, FromRequestParts}, response::{IntoResponse, IntoResponseParts}, @@ -383,7 +382,7 @@ struct TestGraphQLState { limiter: Limiter, } -#[async_trait] +#[async_trait::async_trait] impl graphql::State for TestGraphQLState { async fn repository(&self) -> Result { let repo = PgRepository::from_pool(&self.pool) @@ -512,7 +511,6 @@ impl FromRef for reqwest::Client { } } -#[async_trait] impl FromRequestParts for ActivityTracker { type Rejection = Infallible; @@ -524,7 +522,6 @@ impl FromRequestParts for ActivityTracker { } } -#[async_trait] impl FromRequestParts for BoundActivityTracker { type Rejection = Infallible; @@ -537,7 +534,6 @@ impl FromRequestParts for BoundActivityTracker { } } -#[async_trait] impl FromRequestParts for RequesterFingerprint { type Rejection = Infallible; @@ -549,7 +545,6 @@ impl FromRequestParts for RequesterFingerprint { } } -#[async_trait] impl FromRequestParts for BoxClock { type Rejection = Infallible; @@ -561,7 +556,6 @@ impl FromRequestParts for BoxClock { } } -#[async_trait] impl FromRequestParts for BoxRng { type Rejection = Infallible; @@ -575,7 +569,6 @@ impl FromRequestParts for BoxRng { } } -#[async_trait] impl FromRequestParts for BoxRepository { type Rejection = ErrorWrapper; @@ -588,7 +581,6 @@ impl FromRequestParts for BoxRepository { } } -#[async_trait] impl FromRequestParts for Policy { type Rejection = ErrorWrapper; diff --git a/crates/handlers/src/upstream_oauth2/callback.rs b/crates/handlers/src/upstream_oauth2/callback.rs index 7a6e0f215..935014448 100644 --- a/crates/handlers/src/upstream_oauth2/callback.rs +++ b/crates/handlers/src/upstream_oauth2/callback.rs @@ -7,10 +7,9 @@ use axum::{ extract::{Path, State}, http::Method, - response::{IntoResponse, Response}, + response::{Html, IntoResponse, Response}, Form, }; -use axum_extra::response::Html; use hyper::StatusCode; use mas_axum_utils::{cookies::CookieJar, sentry::SentryEventID}; use mas_data_model::{UpstreamOAuthProvider, UpstreamOAuthProviderResponseMode}; @@ -162,7 +161,7 @@ pub(crate) async fn handler( PreferredLanguage(locale): PreferredLanguage, cookie_jar: CookieJar, Path(provider_id): Path, - params: Option>, + Form(params): Form>, ) -> Result { let provider = repo .upstream_oauth_provider() @@ -173,7 +172,7 @@ pub(crate) async fn handler( let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); - let Some(Form(params)) = params else { + let Some(params) = params else { if let Method::GET = method { return Err(RouteError::MissingQueryParams); } diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index 3f887ba3b..235dee093 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -12,23 +12,29 @@ use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt}; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository}; use mas_templates::{AppContext, TemplateContext, Templates}; +use serde::Deserialize; use crate::{BoundActivityTracker, PreferredLanguage}; +#[derive(Deserialize)] +pub struct Params { + #[serde(default, flatten)] + action: Option, +} + #[tracing::instrument(name = "handlers.views.app.get", skip_all, err)] pub async fn get( PreferredLanguage(locale): PreferredLanguage, State(templates): State, activity_tracker: BoundActivityTracker, State(url_builder): State, - action: Option>, + Query(Params { action }): Query, mut repo: BoxRepository, clock: BoxClock, cookie_jar: CookieJar, ) -> Result { let (session_info, cookie_jar) = cookie_jar.session_info(); let session = session_info.load_session(&mut repo).await?; - let action = action.map(|Query(a)| a); // TODO: keep the full path, not just the action let Some(session) = session else { diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index b0d6991c0..1203dc2e0 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -168,6 +168,11 @@ pub(crate) async fn post( return Ok((cookie_jar, Html(content)).into_response()); } + // Extract the localpart of the MXID, fallback to the bare username + let username = homeserver + .localpart(&form.username) + .unwrap_or(&form.username); + match login( password_manager, &mut repo, @@ -175,7 +180,7 @@ pub(crate) async fn post( &clock, limiter, requester, - &form.username, + username, &form.password, user_agent, ) @@ -479,23 +484,17 @@ mod test { .contains(&escape_html(&second_provider_login.path_and_query()))); } - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_password_login(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); + async fn user_with_password(state: &TestState, username: &str, password: &str) { let mut rng = state.rng(); - let cookies = CookieHelper::new(); - - // Provision a user with a password let mut repo = state.repository().await.unwrap(); let user = repo .user() - .add(&mut rng, &state.clock, "john".to_owned()) + .add(&mut rng, &state.clock, username.to_owned()) .await .unwrap(); let (version, hash) = state .password_manager - .hash(&mut rng, Zeroizing::new("hunter2".as_bytes().to_vec())) + .hash(&mut rng, Zeroizing::new(password.as_bytes().to_vec())) .await .unwrap(); repo.user_password() @@ -503,6 +502,16 @@ mod test { .await .unwrap(); repo.save().await.unwrap(); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_password_login(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Provision a user with a password + user_with_password(&state, "john", "hunter2").await; // Render the login page to get a CSRF token let request = Request::get("/login").empty(); @@ -542,6 +551,93 @@ mod test { assert!(response.body().contains("john")); } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_password_login_with_mxid(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Provision a user with a password + user_with_password(&state, "john", "hunter2").await; + + // Render the login page to get a CSRF token + let request = Request::get("/login").empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the login form + let request = Request::post("/login").form(serde_json::json!({ + "csrf": csrf_token, + "username": "@john:example.com", + "password": "hunter2", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + + // Now if we get to the home page, we should see the user's username + let request = Request::get("/").empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + assert!(response.body().contains("john")); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_password_login_with_mxid_wrong_server(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Provision a user with a password + user_with_password(&state, "john", "hunter2").await; + + // Render the login page to get a CSRF token + let request = Request::get("/login").empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the login form + let request = Request::post("/login").form(serde_json::json!({ + "csrf": csrf_token, + "username": "@john:something.corp", + "password": "hunter2", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + + // This shouldn't have worked, we're back on the login page + response.assert_status(StatusCode::OK); + assert!(response.body().contains("Invalid credentials")); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_password_login_rate_limit(pool: PgPool) { setup(); diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index ea8cb40ce..a209a83a4 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -5,9 +5,8 @@ use axum::{ extract::{Query, State}, - response::{IntoResponse, Response}, + response::{Html, IntoResponse, Response}, }; -use axum_extra::response::Html; use mas_axum_utils::{cookies::CookieJar, csrf::CsrfExt as _, FancyError, SessionInfoExt}; use mas_data_model::SiteConfig; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; diff --git a/crates/i18n-scan/src/main.rs b/crates/i18n-scan/src/main.rs index 059289ea9..f514d9c09 100644 --- a/crates/i18n-scan/src/main.rs +++ b/crates/i18n-scan/src/main.rs @@ -8,7 +8,7 @@ // which is annoying with this clippy lint #![allow(clippy::default_constructed_unit_structs)] -use std::fs::File; +use std::{fs::File, io::BufReader}; use ::minijinja::{machinery::WhitespaceConfig, syntax::SyntaxConfig}; use camino::Utf8PathBuf; @@ -50,8 +50,9 @@ fn main() { // Open the existing translation file if one was provided let mut tree = if let Some(path) = &options.existing { - let mut file = File::open(path).expect("Failed to open existing translation file"); - serde_json::from_reader(&mut file).expect("Failed to parse existing translation file") + let file = File::open(path).expect("Failed to open existing translation file"); + let mut reader = BufReader::new(file); + serde_json::from_reader(&mut reader).expect("Failed to parse existing translation file") } else { TranslationTree::default() }; diff --git a/crates/i18n/src/translator.rs b/crates/i18n/src/translator.rs index 7a6b2f3c9..6ba81aa32 100644 --- a/crates/i18n/src/translator.rs +++ b/crates/i18n/src/translator.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use std::{collections::HashMap, fs::File, str::FromStr}; +use std::{collections::HashMap, fs::File, io::BufReader, str::FromStr}; use camino::{Utf8Path, Utf8PathBuf}; use icu_list::{ListError, ListFormatter, ListLength}; @@ -135,12 +135,14 @@ impl Translator { Err(source) => return Err(LoadError::InvalidLocale { path, source }), }; - let mut file = match File::open(&path) { + let file = match File::open(&path) { Ok(file) => file, Err(source) => return Err(LoadError::ReadFile { path, source }), }; - let content = match serde_json::from_reader(&mut file) { + let mut reader = BufReader::new(file); + + let content = match serde_json::from_reader(&mut reader) { Ok(content) => content, Err(source) => return Err(LoadError::Deserialize { path, source }), }; diff --git a/crates/matrix/Cargo.toml b/crates/matrix/Cargo.toml index 8f7e77579..4f194bd22 100644 --- a/crates/matrix/Cargo.toml +++ b/crates/matrix/Cargo.toml @@ -15,3 +15,4 @@ workspace = true anyhow.workspace = true async-trait.workspace = true tokio.workspace = true +ruma-common.workspace = true diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index 700bb15d8..76f32e09f 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -8,6 +8,8 @@ mod mock; use std::{collections::HashSet, sync::Arc}; +use ruma_common::UserId; + pub use self::mock::HomeserverConnection as MockHomeserverConnection; // TODO: this should probably be another error type by default @@ -193,6 +195,22 @@ pub trait HomeserverConnection: Send + Sync { format!("@{}:{}", localpart, self.homeserver()) } + /// Get the localpart of a Matrix ID if it has the right server name + /// + /// Returns [`None`] if the input isn't a valid MXID, or if the server name + /// doesn't match + /// + /// # Parameters + /// + /// * `mxid` - The MXID of the user + fn localpart<'a>(&self, mxid: &'a str) -> Option<&'a str> { + let mxid = <&UserId>::try_from(mxid).ok()?; + if mxid.server_name() != self.homeserver() { + return None; + } + Some(mxid.localpart()) + } + /// Query the state of a user on the homeserver. /// /// # Parameters diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index fa707a66b..2015d3e22 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -444,7 +444,7 @@ impl From> for PasswordRegister { } } -/// `GET|POST /register/steps/:id/display-name` +/// `GET|POST /register/steps/{id}/display-name` #[derive(Debug, Clone)] pub struct RegisterDisplayName { id: Ulid, @@ -460,7 +460,7 @@ impl RegisterDisplayName { impl Route for RegisterDisplayName { type Query = (); fn route() -> &'static str { - "/register/steps/:id/display-name" + "/register/steps/{id}/display-name" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -468,7 +468,7 @@ impl Route for RegisterDisplayName { } } -/// `GET|POST /register/steps/:id/verify-email` +/// `GET|POST /register/steps/{id}/verify-email` #[derive(Debug, Clone)] pub struct RegisterVerifyEmail { id: Ulid, @@ -484,7 +484,7 @@ impl RegisterVerifyEmail { impl Route for RegisterVerifyEmail { type Query = (); fn route() -> &'static str { - "/register/steps/:id/verify-email" + "/register/steps/{id}/verify-email" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -492,7 +492,7 @@ impl Route for RegisterVerifyEmail { } } -/// `GET /register/steps/:id/finish` +/// `GET /register/steps/{id}/finish` #[derive(Debug, Clone)] pub struct RegisterFinish { id: Ulid, @@ -508,7 +508,7 @@ impl RegisterFinish { impl Route for RegisterFinish { type Query = (); fn route() -> &'static str { - "/register/steps/:id/finish" + "/register/steps/{id}/finish" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -567,7 +567,7 @@ impl Route for Account { pub struct AccountWildcard; impl SimpleRoute for AccountWildcard { - const PATH: &'static str = "/account/*rest"; + const PATH: &'static str = "/account/{*rest}"; } /// `GET /account/password/change` @@ -581,14 +581,14 @@ impl SimpleRoute for AccountPasswordChange { const PATH: &'static str = "/account/password/change"; } -/// `GET /authorize/:grant_id` +/// `GET /authorize/{grant_id}` #[derive(Debug, Clone)] pub struct ContinueAuthorizationGrant(pub Ulid); impl Route for ContinueAuthorizationGrant { type Query = (); fn route() -> &'static str { - "/authorize/:grant_id" + "/authorize/{grant_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -596,14 +596,14 @@ impl Route for ContinueAuthorizationGrant { } } -/// `GET /consent/:grant_id` +/// `GET /consent/{grant_id}` #[derive(Debug, Clone)] pub struct Consent(pub Ulid); impl Route for Consent { type Query = (); fn route() -> &'static str { - "/consent/:grant_id" + "/consent/{grant_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -615,28 +615,28 @@ impl Route for Consent { pub struct CompatLogin; impl SimpleRoute for CompatLogin { - const PATH: &'static str = "/_matrix/client/:version/login"; + const PATH: &'static str = "/_matrix/client/{version}/login"; } /// `POST /_matrix/client/v3/logout` pub struct CompatLogout; impl SimpleRoute for CompatLogout { - const PATH: &'static str = "/_matrix/client/:version/logout"; + const PATH: &'static str = "/_matrix/client/{version}/logout"; } /// `POST /_matrix/client/v3/refresh` pub struct CompatRefresh; impl SimpleRoute for CompatRefresh { - const PATH: &'static str = "/_matrix/client/:version/refresh"; + const PATH: &'static str = "/_matrix/client/{version}/refresh"; } /// `GET /_matrix/client/v3/login/sso/redirect` pub struct CompatLoginSsoRedirect; impl SimpleRoute for CompatLoginSsoRedirect { - const PATH: &'static str = "/_matrix/client/:version/login/sso/redirect"; + const PATH: &'static str = "/_matrix/client/{version}/login/sso/redirect"; } /// `GET /_matrix/client/v3/login/sso/redirect/` @@ -646,14 +646,14 @@ impl SimpleRoute for CompatLoginSsoRedirect { pub struct CompatLoginSsoRedirectSlash; impl SimpleRoute for CompatLoginSsoRedirectSlash { - const PATH: &'static str = "/_matrix/client/:version/login/sso/redirect/"; + const PATH: &'static str = "/_matrix/client/{version}/login/sso/redirect/"; } -/// `GET /_matrix/client/v3/login/sso/redirect/:idp` +/// `GET /_matrix/client/v3/login/sso/redirect/{idp}` pub struct CompatLoginSsoRedirectIdp; impl SimpleRoute for CompatLoginSsoRedirectIdp { - const PATH: &'static str = "/_matrix/client/:version/login/sso/redirect/:idp"; + const PATH: &'static str = "/_matrix/client/{version}/login/sso/redirect/{idp}"; } #[derive(Debug, Serialize, Deserialize, Clone, Copy)] @@ -669,7 +669,7 @@ pub struct CompatLoginSsoActionParams { action: CompatLoginSsoAction, } -/// `GET|POST /complete-compat-sso/:id` +/// `GET|POST /complete-compat-sso/{id}` pub struct CompatLoginSsoComplete { id: Ulid, query: Option, @@ -693,7 +693,7 @@ impl Route for CompatLoginSsoComplete { } fn route() -> &'static str { - "/complete-compat-sso/:grant_id" + "/complete-compat-sso/{grant_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -701,7 +701,7 @@ impl Route for CompatLoginSsoComplete { } } -/// `GET /upstream/authorize/:id` +/// `GET /upstream/authorize/{id}` pub struct UpstreamOAuth2Authorize { id: Ulid, post_auth_action: Option, @@ -726,7 +726,7 @@ impl UpstreamOAuth2Authorize { impl Route for UpstreamOAuth2Authorize { type Query = PostAuthAction; fn route() -> &'static str { - "/upstream/authorize/:provider_id" + "/upstream/authorize/{provider_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -738,7 +738,7 @@ impl Route for UpstreamOAuth2Authorize { } } -/// `GET /upstream/callback/:id` +/// `GET /upstream/callback/{id}` pub struct UpstreamOAuth2Callback { id: Ulid, } @@ -753,7 +753,7 @@ impl UpstreamOAuth2Callback { impl Route for UpstreamOAuth2Callback { type Query = (); fn route() -> &'static str { - "/upstream/callback/:provider_id" + "/upstream/callback/{provider_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -761,7 +761,7 @@ impl Route for UpstreamOAuth2Callback { } } -/// `GET /upstream/link/:id` +/// `GET /upstream/link/{id}` pub struct UpstreamOAuth2Link { id: Ulid, } @@ -776,7 +776,7 @@ impl UpstreamOAuth2Link { impl Route for UpstreamOAuth2Link { type Query = (); fn route() -> &'static str { - "/upstream/link/:link_id" + "/upstream/link/{link_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -808,7 +808,7 @@ impl Route for DeviceCodeLink { } } -/// `GET|POST /device/:device_code_id` +/// `GET|POST /device/{device_code_id}` #[derive(Default, Serialize, Deserialize, Debug, Clone)] pub struct DeviceCodeConsent { id: Ulid, @@ -817,7 +817,7 @@ pub struct DeviceCodeConsent { impl Route for DeviceCodeConsent { type Query = (); fn route() -> &'static str { - "/device/:device_code_id" + "/device/{device_code_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { @@ -848,7 +848,7 @@ impl SimpleRoute for AccountRecoveryStart { const PATH: &'static str = "/recover"; } -/// `GET|POST /recover/progress/:session_id` +/// `GET|POST /recover/progress/{session_id}` #[derive(Default, Serialize, Deserialize, Debug, Clone)] pub struct AccountRecoveryProgress { session_id: Ulid, @@ -864,7 +864,7 @@ impl AccountRecoveryProgress { impl Route for AccountRecoveryProgress { type Query = (); fn route() -> &'static str { - "/recover/progress/:session_id" + "/recover/progress/{session_id}" } fn path(&self) -> std::borrow::Cow<'static, str> { diff --git a/crates/storage-pg/.sqlx/query-bb6f55a4cc10bec8ec0fc138485f6b4d308302bb1fa3accb12932d1e5ce457e9.json b/crates/storage-pg/.sqlx/query-bb6f55a4cc10bec8ec0fc138485f6b4d308302bb1fa3accb12932d1e5ce457e9.json index 2c81606b9..1360f4ba8 100644 --- a/crates/storage-pg/.sqlx/query-bb6f55a4cc10bec8ec0fc138485f6b4d308302bb1fa3accb12932d1e5ce457e9.json +++ b/crates/storage-pg/.sqlx/query-bb6f55a4cc10bec8ec0fc138485f6b4d308302bb1fa3accb12932d1e5ce457e9.json @@ -61,7 +61,7 @@ }, "nullable": [ false, - false, + true, false, true, false, diff --git a/crates/storage-pg/migrations/20250114135939_allow_deviceless_compat_sessions.sql b/crates/storage-pg/migrations/20250114135939_allow_deviceless_compat_sessions.sql new file mode 100644 index 000000000..8bf40f727 --- /dev/null +++ b/crates/storage-pg/migrations/20250114135939_allow_deviceless_compat_sessions.sql @@ -0,0 +1,7 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Drop the `NOT NULL` requirement on compat sessions, so we can import device-less access tokens from Synapse. +ALTER TABLE compat_sessions ALTER COLUMN device_id DROP NOT NULL; diff --git a/crates/storage-pg/migrations/20250124151529_unsupported_threepids_table.sql b/crates/storage-pg/migrations/20250124151529_unsupported_threepids_table.sql new file mode 100644 index 000000000..f00cb3247 --- /dev/null +++ b/crates/storage-pg/migrations/20250124151529_unsupported_threepids_table.sql @@ -0,0 +1,30 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + + + +-- Tracks third-party ID associations that have been verified but are +-- not currently supported by MAS. +-- This is currently used when importing third-party IDs from Synapse, +-- which historically could verify at least phone numbers. +-- E-mail associations will not be stored in this table because those are natively +-- supported by MAS; see the `user_emails` table. + +CREATE TABLE user_unsupported_third_party_ids( + -- The owner of the third-party ID assocation + user_id UUID NOT NULL + REFERENCES users(user_id) ON DELETE CASCADE, + + -- What type of association is this? + medium TEXT NOT NULL, + + -- The address of the associated ID, e.g. a phone number or other identifier. + address TEXT NOT NULL, + + -- When the association was created + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + + PRIMARY KEY (user_id, medium, address) +); diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index 6905dbc1e..7a71036c9 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -117,16 +117,19 @@ impl TryFrom for AppSession { None, Some(user_id), None, - Some(device_id), + device_id_opt, Some(is_synapse_admin), ) => { let id = compat_session_id.into(); - let device = Device::try_from(device_id).map_err(|e| { - DatabaseInconsistencyError::on("compat_sessions") - .column("device_id") - .row(id) - .source(e) - })?; + let device = device_id_opt + .map(Device::try_from) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("compat_sessions") + .column("device_id") + .row(id) + .source(e) + })?; let state = match finished_at { None => CompatSessionState::Valid, diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index e58fa9071..93dd38447 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -83,7 +83,7 @@ mod tests { .await .unwrap(); assert_eq!(session.user_id, user.id); - assert_eq!(session.device.as_str(), device_str); + assert_eq!(session.device.as_ref().unwrap().as_str(), device_str); assert!(session.is_valid()); assert!(!session.is_finished()); @@ -117,7 +117,7 @@ mod tests { .expect("compat session not found"); assert_eq!(session_lookup.id, session.id); assert_eq!(session_lookup.user_id, user.id); - assert_eq!(session_lookup.device.as_str(), device_str); + assert_eq!(session.device.as_ref().unwrap().as_str(), device_str); assert!(session_lookup.is_valid()); assert!(!session_lookup.is_finished()); @@ -154,7 +154,7 @@ mod tests { let session_lookup = &list.edges[0].0; assert_eq!(session_lookup.id, session.id); assert_eq!(session_lookup.user_id, user.id); - assert_eq!(session_lookup.device.as_str(), device_str); + assert_eq!(session.device.as_ref().unwrap().as_str(), device_str); assert!(session_lookup.is_valid()); assert!(!session_lookup.is_finished()); diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index 12001cfe6..de5ea6b2f 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -47,7 +47,7 @@ impl<'c> PgCompatSessionRepository<'c> { struct CompatSessionLookup { compat_session_id: Uuid, - device_id: String, + device_id: Option, user_id: Uuid, user_session_id: Option, created_at: DateTime, @@ -63,12 +63,16 @@ impl TryFrom for CompatSession { fn try_from(value: CompatSessionLookup) -> Result { let id = value.compat_session_id.into(); - let device = Device::try_from(value.device_id).map_err(|e| { - DatabaseInconsistencyError::on("compat_sessions") - .column("device_id") - .row(id) - .source(e) - })?; + let device = value + .device_id + .map(Device::try_from) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("compat_sessions") + .column("device_id") + .row(id) + .source(e) + })?; let state = match value.finished_at { None => CompatSessionState::Valid, @@ -96,7 +100,7 @@ impl TryFrom for CompatSession { #[enum_def] struct CompatSessionAndSsoLoginLookup { compat_session_id: Uuid, - device_id: String, + device_id: Option, user_id: Uuid, user_session_id: Option, created_at: DateTime, @@ -118,12 +122,16 @@ impl TryFrom for (CompatSession, Option Result { let id = value.compat_session_id.into(); - let device = Device::try_from(value.device_id).map_err(|e| { - DatabaseInconsistencyError::on("compat_sessions") - .column("device_id") - .row(id) - .source(e) - })?; + let device = value + .device_id + .map(Device::try_from) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("compat_sessions") + .column("device_id") + .row(id) + .source(e) + })?; let state = match value.finished_at { None => CompatSessionState::Valid, @@ -347,7 +355,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { id, state: CompatSessionState::default(), user_id: user.id, - device, + device: Some(device), user_session_id: browser_session.map(|s| s.id), created_at, is_synapse_admin, @@ -364,7 +372,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { db.query.text, %compat_session.id, user.id = %compat_session.user_id, - compat_session.device.id = compat_session.device.as_str(), + compat_session.device.id = compat_session.device.as_ref().map(mas_data_model::Device::as_str), ), err, )] diff --git a/crates/storage-pg/src/compat/sso_login.rs b/crates/storage-pg/src/compat/sso_login.rs index 5e6121a81..8b75ab130 100644 --- a/crates/storage-pg/src/compat/sso_login.rs +++ b/crates/storage-pg/src/compat/sso_login.rs @@ -293,7 +293,7 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> { db.query.text, %compat_sso_login.id, %compat_session.id, - compat_session.device.id = compat_session.device.as_str(), + compat_session.device.id = compat_session.device.as_ref().map(mas_data_model::Device::as_str), user.id = %compat_session.user_id, ), err, diff --git a/crates/syn2mas/.sqlx/query-07ec66733b67a9990cc9d483b564c8d05c577cf8f049d8822746c7d1dbd23752.json b/crates/syn2mas/.sqlx/query-07ec66733b67a9990cc9d483b564c8d05c577cf8f049d8822746c7d1dbd23752.json new file mode 100644 index 000000000..c7f5fce5e --- /dev/null +++ b/crates/syn2mas/.sqlx/query-07ec66733b67a9990cc9d483b564c8d05c577cf8f049d8822746c7d1dbd23752.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas_restore_indices (name, table_name, definition)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "07ec66733b67a9990cc9d483b564c8d05c577cf8f049d8822746c7d1dbd23752" +} diff --git a/crates/syn2mas/.sqlx/query-12112011318abc0bdd7f722ed8c5d4a86bf5758f8c32d9d41a22999b2f0698ca.json b/crates/syn2mas/.sqlx/query-12112011318abc0bdd7f722ed8c5d4a86bf5758f8c32d9d41a22999b2f0698ca.json new file mode 100644 index 000000000..f1b8bad90 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-12112011318abc0bdd7f722ed8c5d4a86bf5758f8c32d9d41a22999b2f0698ca.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT conrelid::regclass::text AS \"table_name!\", conname AS \"name!\", pg_get_constraintdef(c.oid) AS \"definition!\"\n FROM pg_constraint c\n JOIN pg_namespace n ON n.oid = c.connamespace\n WHERE contype IN ('f', 'p', 'u') AND conrelid::regclass::text = $1\n AND n.nspname = current_schema;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "table_name!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Name" + }, + { + "ordinal": 2, + "name": "definition!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + false, + null + ] + }, + "hash": "12112011318abc0bdd7f722ed8c5d4a86bf5758f8c32d9d41a22999b2f0698ca" +} diff --git a/crates/syn2mas/.sqlx/query-486f3177dcf6117c6b966954a44d9f96a754eba64912566e81a90bd4cbd186f0.json b/crates/syn2mas/.sqlx/query-486f3177dcf6117c6b966954a44d9f96a754eba64912566e81a90bd4cbd186f0.json new file mode 100644 index 000000000..68b0722e1 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-486f3177dcf6117c6b966954a44d9f96a754eba64912566e81a90bd4cbd186f0.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT indexname AS \"name!\", indexdef AS \"definition!\", schemaname AS \"table_name!\"\n FROM pg_indexes\n WHERE schemaname = current_schema AND tablename = $1 AND indexname IS NOT NULL AND indexdef IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name!", + "type_info": "Name" + }, + { + "ordinal": 1, + "name": "definition!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "table_name!", + "type_info": "Name" + } + ], + "parameters": { + "Left": [ + "Name" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "486f3177dcf6117c6b966954a44d9f96a754eba64912566e81a90bd4cbd186f0" +} diff --git a/crates/syn2mas/.sqlx/query-5b4840f42ae00c5dc9f59f2745d664b16ebd813dfa0aa32a6d39dd5c393af299.json b/crates/syn2mas/.sqlx/query-5b4840f42ae00c5dc9f59f2745d664b16ebd813dfa0aa32a6d39dd5c393af299.json new file mode 100644 index 000000000..3dcc1fc48 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-5b4840f42ae00c5dc9f59f2745d664b16ebd813dfa0aa32a6d39dd5c393af299.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT conrelid::regclass::text AS \"table_name!\", conname AS \"name!\", pg_get_constraintdef(c.oid) AS \"definition!\"\n FROM pg_constraint c\n JOIN pg_namespace n ON n.oid = c.connamespace\n WHERE contype = 'f' AND confrelid::regclass::text = $1\n AND n.nspname = current_schema;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "table_name!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Name" + }, + { + "ordinal": 2, + "name": "definition!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + false, + null + ] + }, + "hash": "5b4840f42ae00c5dc9f59f2745d664b16ebd813dfa0aa32a6d39dd5c393af299" +} diff --git a/crates/syn2mas/.sqlx/query-69aa96208513c3ea64a446c7739747fcb5e79d7e8c1212b2a679c3bde908ce93.json b/crates/syn2mas/.sqlx/query-69aa96208513c3ea64a446c7739747fcb5e79d7e8c1212b2a679c3bde908ce93.json new file mode 100644 index 000000000..855da3ba6 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-69aa96208513c3ea64a446c7739747fcb5e79d7e8c1212b2a679c3bde908ce93.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas_restore_constraints (name, table_name, definition)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "69aa96208513c3ea64a446c7739747fcb5e79d7e8c1212b2a679c3bde908ce93" +} diff --git a/crates/syn2mas/.sqlx/query-78ed3bf1032cd678b42230d68fb2b8e3d74161c8b6c5fe1a746b6958ccd2fd84.json b/crates/syn2mas/.sqlx/query-78ed3bf1032cd678b42230d68fb2b8e3d74161c8b6c5fe1a746b6958ccd2fd84.json new file mode 100644 index 000000000..759cc5f8b --- /dev/null +++ b/crates/syn2mas/.sqlx/query-78ed3bf1032cd678b42230d68fb2b8e3d74161c8b6c5fe1a746b6958ccd2fd84.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT table_name, name, definition FROM syn2mas_restore_constraints ORDER BY order_key", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "table_name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "definition", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "78ed3bf1032cd678b42230d68fb2b8e3d74161c8b6c5fe1a746b6958ccd2fd84" +} diff --git a/crates/syn2mas/.sqlx/query-979bedd942b4f71c58f3672f2917cee05ac1a628e51fe61ba6dfed253e0c63c2.json b/crates/syn2mas/.sqlx/query-979bedd942b4f71c58f3672f2917cee05ac1a628e51fe61ba6dfed253e0c63c2.json new file mode 100644 index 000000000..9ae8f1e35 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-979bedd942b4f71c58f3672f2917cee05ac1a628e51fe61ba6dfed253e0c63c2.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT table_name, name, definition FROM syn2mas_restore_indices ORDER BY order_key", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "table_name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "definition", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "979bedd942b4f71c58f3672f2917cee05ac1a628e51fe61ba6dfed253e0c63c2" +} diff --git a/crates/syn2mas/.sqlx/query-b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b.json b/crates/syn2mas/.sqlx/query-b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b.json new file mode 100644 index 000000000..b44dfc605 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__user_unsupported_third_party_ids\n (user_id, medium, address, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TextArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b" +} diff --git a/crates/syn2mas/.sqlx/query-b27828d7510d52456b50b4c4b9712878ee329ca72070d849eb61ac9c8f9d1c76.json b/crates/syn2mas/.sqlx/query-b27828d7510d52456b50b4c4b9712878ee329ca72070d849eb61ac9c8f9d1c76.json new file mode 100644 index 000000000..df1f3fb7c --- /dev/null +++ b/crates/syn2mas/.sqlx/query-b27828d7510d52456b50b4c4b9712878ee329ca72070d849eb61ac9c8f9d1c76.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 AS _dummy FROM pg_tables WHERE schemaname = current_schema\n AND tablename = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "_dummy", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "NameArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b27828d7510d52456b50b4c4b9712878ee329ca72070d849eb61ac9c8f9d1c76" +} diff --git a/crates/syn2mas/.sqlx/query-c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425.json b/crates/syn2mas/.sqlx/query-c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425.json new file mode 100644 index 000000000..efa2c4d24 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__user_passwords\n (user_password_id, user_id, hashed_password, created_at, version)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray", + "Int4Array" + ] + }, + "nullable": [] + }, + "hash": "c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425" +} diff --git a/crates/syn2mas/.sqlx/query-c7d2277606b4b326b0c375a056cd57488c930fe431311e53e5e1af6fb1d4e56f.json b/crates/syn2mas/.sqlx/query-c7d2277606b4b326b0c375a056cd57488c930fe431311e53e5e1af6fb1d4e56f.json new file mode 100644 index 000000000..d8be21736 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-c7d2277606b4b326b0c375a056cd57488c930fe431311e53e5e1af6fb1d4e56f.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__users\n (user_id, username, created_at, locked_at, can_request_admin)\n SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[], $5::BOOL[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TextArray", + "TimestamptzArray", + "TimestamptzArray", + "BoolArray" + ] + }, + "nullable": [] + }, + "hash": "c7d2277606b4b326b0c375a056cd57488c930fe431311e53e5e1af6fb1d4e56f" +} diff --git a/crates/syn2mas/.sqlx/query-d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32.json b/crates/syn2mas/.sqlx/query-d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32.json new file mode 100644 index 000000000..f6ac32781 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__upstream_oauth_links\n (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32" +} diff --git a/crates/syn2mas/.sqlx/query-dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00.json b/crates/syn2mas/.sqlx/query-dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00.json new file mode 100644 index 000000000..cf89130f9 --- /dev/null +++ b/crates/syn2mas/.sqlx/query-dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO syn2mas__user_emails\n (user_email_id, user_id, email, created_at, confirmed_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "UuidArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00" +} diff --git a/crates/syn2mas/Cargo.toml b/crates/syn2mas/Cargo.toml new file mode 100644 index 000000000..a7075c7f0 --- /dev/null +++ b/crates/syn2mas/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "syn2mas" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow.workspace = true +camino.workspace = true +figment.workspace = true +serde.workspace = true +thiserror.workspace = true +thiserror-ext.workspace = true +tokio.workspace = true +sqlx.workspace = true +chrono.workspace = true +compact_str.workspace = true +tracing.workspace = true +futures-util = "0.3.30" + +rand.workspace = true +uuid = "1.10.0" +ulid = { workspace = true, features = ["uuid"] } + +mas-config.workspace = true + +[dev-dependencies] +mas-storage-pg.workspace = true + +anyhow.workspace = true +insta.workspace = true +serde.workspace = true + +[lints] +workspace = true diff --git a/crates/syn2mas/src/lib.rs b/crates/syn2mas/src/lib.rs new file mode 100644 index 000000000..723ebc869 --- /dev/null +++ b/crates/syn2mas/src/lib.rs @@ -0,0 +1,20 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod mas_writer; +mod synapse_reader; + +mod migration; + +pub use self::{ + mas_writer::{checks::mas_pre_migration_checks, locking::LockedMasDatabase, MasWriter}, + migration::migrate, + synapse_reader::{ + checks::{ + synapse_config_check, synapse_config_check_against_mas_config, synapse_database_check, + }, + config as synapse_config, SynapseReader, + }, +}; diff --git a/crates/syn2mas/src/mas_writer/checks.rs b/crates/syn2mas/src/mas_writer/checks.rs new file mode 100644 index 000000000..a8ea1a18a --- /dev/null +++ b/crates/syn2mas/src/mas_writer/checks.rs @@ -0,0 +1,77 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! # MAS Database Checks +//! +//! This module provides safety checks to run against a MAS database before +//! running the Synapse-to-MAS migration. + +use thiserror::Error; +use thiserror_ext::ContextInto; + +use super::{is_syn2mas_in_progress, locking::LockedMasDatabase, MAS_TABLES_AFFECTED_BY_MIGRATION}; + +#[derive(Debug, Error, ContextInto)] +pub enum Error { + #[error("the MAS database is not empty: rows found in at least `{table}`")] + MasDatabaseNotEmpty { table: &'static str }, + + #[error("query against {table} failed — is this actually a MAS database?")] + MaybeNotMas { + #[source] + source: sqlx::Error, + table: &'static str, + }, + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error("unable to check if syn2mas is already in progress")] + UnableToCheckInProgress(#[source] super::Error), +} + +/// Check that a MAS database is ready for being migrated to. +/// +/// Concretely, this checks that the database is empty. +/// +/// If syn2mas is already in progress on this database, the checks are skipped. +/// +/// # Errors +/// +/// Errors are returned under the following circumstances: +/// +/// - If any database access error occurs. +/// - If any MAS tables involved in the migration are not empty. +/// - If we can't check whether syn2mas is already in progress on this database +/// or not. +#[tracing::instrument(skip_all)] +pub async fn mas_pre_migration_checks<'a>( + mas_connection: &mut LockedMasDatabase<'a>, +) -> Result<(), Error> { + if is_syn2mas_in_progress(mas_connection.as_mut()) + .await + .map_err(Error::UnableToCheckInProgress)? + { + // syn2mas already in progress, so we already performed the checks + return Ok(()); + } + + // Check that the database looks like a MAS database and that it is also an + // empty database. + + for &table in MAS_TABLES_AFFECTED_BY_MIGRATION { + let row_present = sqlx::query(&format!("SELECT 1 AS dummy FROM {table} LIMIT 1")) + .fetch_optional(mas_connection.as_mut()) + .await + .into_maybe_not_mas(table)? + .is_some(); + + if row_present { + return Err(Error::MasDatabaseNotEmpty { table }); + } + } + + Ok(()) +} diff --git a/crates/syn2mas/src/mas_writer/constraint_pausing.rs b/crates/syn2mas/src/mas_writer/constraint_pausing.rs new file mode 100644 index 000000000..6a420888f --- /dev/null +++ b/crates/syn2mas/src/mas_writer/constraint_pausing.rs @@ -0,0 +1,151 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use sqlx::PgConnection; +use tracing::debug; + +use super::{Error, IntoDatabase}; + +/// Description of a constraint, which allows recreating it later. +pub struct ConstraintDescription { + pub name: String, + pub table_name: String, + pub definition: String, +} + +pub struct IndexDescription { + pub name: String, + pub table_name: String, + pub definition: String, +} + +/// Look up and return the definition of a constraint. +pub async fn describe_constraints_on_table( + conn: &mut PgConnection, + table_name: &str, +) -> Result, Error> { + sqlx::query_as!( + ConstraintDescription, + r#" + SELECT conrelid::regclass::text AS "table_name!", conname AS "name!", pg_get_constraintdef(c.oid) AS "definition!" + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE contype IN ('f', 'p', 'u') AND conrelid::regclass::text = $1 + AND n.nspname = current_schema; + "#, + table_name + ).fetch_all(&mut *conn).await.into_database_with(|| format!("could not read constraint definitions of {table_name}")) +} + +/// Look up and return the definitions of foreign-key constraints whose +/// target table is the one specified. +pub async fn describe_foreign_key_constraints_to_table( + conn: &mut PgConnection, + target_table_name: &str, +) -> Result, Error> { + sqlx::query_as!( + ConstraintDescription, + r#" + SELECT conrelid::regclass::text AS "table_name!", conname AS "name!", pg_get_constraintdef(c.oid) AS "definition!" + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE contype = 'f' AND confrelid::regclass::text = $1 + AND n.nspname = current_schema; + "#, + target_table_name + ).fetch_all(&mut *conn).await.into_database_with(|| format!("could not read FK constraint definitions targetting {target_table_name}")) +} + +/// Look up and return the definitions of all indices on a given table. +pub async fn describe_indices_on_table( + conn: &mut PgConnection, + table_name: &str, +) -> Result, Error> { + sqlx::query_as!( + IndexDescription, + r#" + SELECT indexname AS "name!", indexdef AS "definition!", schemaname AS "table_name!" + FROM pg_indexes + WHERE schemaname = current_schema AND tablename = $1 AND indexname IS NOT NULL AND indexdef IS NOT NULL + "#, + table_name + ).fetch_all(&mut *conn).await.into_database("cannot search for indices") +} + +/// Drops a constraint from the database. +/// +/// The constraint must exist prior to this call. +pub async fn drop_constraint( + conn: &mut PgConnection, + constraint: &ConstraintDescription, +) -> Result<(), Error> { + let name = &constraint.name; + let table_name = &constraint.table_name; + debug!("dropping constraint {name} on table {table_name}"); + sqlx::query(&format!("ALTER TABLE {table_name} DROP CONSTRAINT {name};")) + .execute(&mut *conn) + .await + .into_database_with(|| format!("failed to drop constraint {name} on {table_name}"))?; + + Ok(()) +} + +/// Drops an index from the database. +/// +/// The index must exist prior to this call. +pub async fn drop_index(conn: &mut PgConnection, index: &IndexDescription) -> Result<(), Error> { + let index_name = &index.name; + debug!("dropping index {index_name}"); + sqlx::query(&format!("DROP INDEX {index_name};")) + .execute(&mut *conn) + .await + .into_database_with(|| format!("failed to temporarily drop {index_name}"))?; + + Ok(()) +} + +/// Restores (recreates) a constraint. +/// +/// The constraint must not exist prior to this call. +pub async fn restore_constraint( + conn: &mut PgConnection, + constraint: &ConstraintDescription, +) -> Result<(), Error> { + let ConstraintDescription { + name, + table_name, + definition, + } = &constraint; + sqlx::query(&format!( + "ALTER TABLE {table_name} ADD CONSTRAINT {name} {definition};" + )) + .execute(conn) + .await + .into_database_with(|| { + format!("failed to recreate constraint {name} on {table_name} with {definition}") + })?; + + Ok(()) +} + +/// Restores (recreates) a index. +/// +/// The index must not exist prior to this call. +pub async fn restore_index(conn: &mut PgConnection, index: &IndexDescription) -> Result<(), Error> { + let IndexDescription { + name, + table_name, + definition, + } = &index; + + sqlx::query(&format!("{definition};")) + .execute(conn) + .await + .into_database_with(|| { + format!("failed to recreate index {name} on {table_name} with {definition}") + })?; + + Ok(()) +} diff --git a/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql b/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql new file mode 100644 index 000000000..7d1e98bc8 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/fixtures/upstream_provider.sql @@ -0,0 +1,16 @@ +INSERT INTO upstream_oauth_providers + ( + upstream_oauth_provider_id, + scope, + client_id, + token_endpoint_auth_method, + created_at + ) + VALUES + ( + '00000000-0000-0000-0000-000000000004', + 'openid', + 'someClientId', + 'client_secret_basic', + '2011-12-13 14:15:16Z' + ); diff --git a/crates/syn2mas/src/mas_writer/locking.rs b/crates/syn2mas/src/mas_writer/locking.rs new file mode 100644 index 000000000..f034025bf --- /dev/null +++ b/crates/syn2mas/src/mas_writer/locking.rs @@ -0,0 +1,60 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::sync::LazyLock; + +use sqlx::{ + postgres::{PgAdvisoryLock, PgAdvisoryLockGuard}, + Either, PgConnection, +}; + +static SYN2MAS_ADVISORY_LOCK: LazyLock = + LazyLock::new(|| PgAdvisoryLock::new("syn2mas-maswriter")); + +/// A wrapper around a Postgres connection which holds a session-wide advisory +/// lock preventing concurrent access by other syn2mas instances. +pub struct LockedMasDatabase<'conn> { + inner: PgAdvisoryLockGuard<'static, &'conn mut PgConnection>, +} + +impl<'conn> LockedMasDatabase<'conn> { + /// Attempts to lock the MAS database against concurrent access by other + /// syn2mas instances. + /// + /// If the lock can be acquired, returns a `LockedMasDatabase`. + /// If the lock cannot be acquired, returns the connection back to the + /// caller wrapped in `Either::Right`. + /// + /// # Errors + /// + /// Errors are returned for underlying database errors. + pub async fn try_new( + mas_connection: &'conn mut PgConnection, + ) -> Result, sqlx::Error> { + SYN2MAS_ADVISORY_LOCK + .try_acquire(mas_connection) + .await + .map(|either| match either { + Either::Left(inner) => Either::Left(LockedMasDatabase { inner }), + Either::Right(unlocked) => Either::Right(unlocked), + }) + } + + /// Releases the advisory lock on the MAS database, returning the underlying + /// connection. + /// + /// # Errors + /// + /// Errors are returned for underlying database errors. + pub async fn unlock(self) -> Result<&'conn mut PgConnection, sqlx::Error> { + self.inner.release_now().await + } +} + +impl AsMut for LockedMasDatabase<'_> { + fn as_mut(&mut self) -> &mut PgConnection { + self.inner.as_mut() + } +} diff --git a/crates/syn2mas/src/mas_writer/mod.rs b/crates/syn2mas/src/mas_writer/mod.rs new file mode 100644 index 000000000..f46a10399 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/mod.rs @@ -0,0 +1,1108 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! # MAS Writer +//! +//! This module is responsible for writing new records to MAS' database. + +use std::fmt::Display; + +use chrono::{DateTime, Utc}; +use futures_util::{future::BoxFuture, FutureExt, TryStreamExt}; +use sqlx::{query, query_as, Executor, PgConnection}; +use thiserror::Error; +use thiserror_ext::{Construct, ContextInto}; +use tokio::sync::mpsc::{self, Receiver, Sender}; +use tracing::{error, info, warn, Level}; +use uuid::Uuid; + +use self::{ + constraint_pausing::{ConstraintDescription, IndexDescription}, + locking::LockedMasDatabase, +}; + +pub mod checks; +pub mod locking; + +mod constraint_pausing; + +#[derive(Debug, Error, Construct, ContextInto)] +pub enum Error { + #[error("database error whilst {context}")] + Database { + #[source] + source: sqlx::Error, + context: String, + }, + + #[error("writer connection pool shut down due to error")] + #[allow(clippy::enum_variant_names)] + WriterConnectionPoolError, + + #[error("inconsistent database: {0}")] + Inconsistent(String), + + #[error("{0}")] + Multiple(MultipleErrors), +} + +#[derive(Debug)] +pub struct MultipleErrors { + errors: Vec, +} + +impl Display for MultipleErrors { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "multiple errors")?; + for error in &self.errors { + write!(f, "\n- {error}")?; + } + Ok(()) + } +} + +impl From> for MultipleErrors { + fn from(value: Vec) -> Self { + MultipleErrors { errors: value } + } +} + +struct WriterConnectionPool { + /// How many connections are in circulation + num_connections: usize, + + /// A receiver handle to get a writer connection + /// The writer connection will be mid-transaction! + connection_rx: Receiver>, + + /// A sender handle to return a writer connection to the pool + /// The connection should still be mid-transaction! + connection_tx: Sender>, +} + +impl WriterConnectionPool { + pub fn new(connections: Vec) -> Self { + let num_connections = connections.len(); + let (connection_tx, connection_rx) = mpsc::channel(num_connections); + for connection in connections { + connection_tx + .try_send(Ok(connection)) + .expect("there should be room for this connection"); + } + + WriterConnectionPool { + num_connections, + connection_rx, + connection_tx, + } + } + + pub async fn spawn_with_connection(&mut self, task: F) -> Result<(), Error> + where + F: for<'conn> FnOnce(&'conn mut PgConnection) -> BoxFuture<'conn, Result<(), Error>> + + Send + + Sync + + 'static, + { + match self.connection_rx.recv().await { + Some(Ok(mut connection)) => { + let connection_tx = self.connection_tx.clone(); + tokio::task::spawn(async move { + let to_return = match task(&mut connection).await { + Ok(()) => Ok(connection), + Err(error) => { + error!("error in writer: {error}"); + Err(error) + } + }; + // This should always succeed in sending unless we're already shutting + // down for some other reason. + let _: Result<_, _> = connection_tx.send(to_return).await; + }); + + Ok(()) + } + Some(Err(error)) => { + // This should always succeed in sending unless we're already shutting + // down for some other reason. + let _: Result<_, _> = self.connection_tx.send(Err(error)).await; + + Err(Error::WriterConnectionPoolError) + } + None => { + unreachable!("we still hold a reference to the sender, so this shouldn't happen") + } + } + } + + /// Finishes writing to the database, committing all changes. + /// + /// # Errors + /// + /// - If any errors were returned to the pool. + /// - If committing the changes failed. + /// + /// # Panics + /// + /// - If connections were not returned to the pool. (This indicates a + /// serious bug.) + pub async fn finish(self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + let Self { + num_connections, + mut connection_rx, + connection_tx, + } = self; + // Drop the sender handle so we gracefully allow the receiver to close + drop(connection_tx); + + let mut finished_connections = 0; + + while let Some(connection_or_error) = connection_rx.recv().await { + finished_connections += 1; + + match connection_or_error { + Ok(mut connection) => { + if let Err(err) = query("COMMIT;").execute(&mut connection).await { + errors.push(err.into_database("commit writer transaction")); + } + } + Err(error) => { + errors.push(error); + } + } + } + assert_eq!(finished_connections, num_connections, "syn2mas had a bug: connections went missing {finished_connections} != {num_connections}"); + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +pub struct MasWriter<'c> { + conn: LockedMasDatabase<'c>, + writer_pool: WriterConnectionPool, + + indices_to_restore: Vec, + constraints_to_restore: Vec, +} + +pub struct MasNewUser { + pub user_id: Uuid, + pub username: String, + pub created_at: DateTime, + pub locked_at: Option>, + pub can_request_admin: bool, +} + +pub struct MasNewUserPassword { + pub user_password_id: Uuid, + pub user_id: Uuid, + pub hashed_password: String, + pub created_at: DateTime, +} + +pub struct MasNewEmailThreepid { + pub user_email_id: Uuid, + pub user_id: Uuid, + pub email: String, + pub created_at: DateTime, +} + +pub struct MasNewUnsupportedThreepid { + pub user_id: Uuid, + pub medium: String, + pub address: String, + pub created_at: DateTime, +} + +pub struct MasNewUpstreamOauthLink { + pub link_id: Uuid, + pub user_id: Uuid, + pub upstream_provider_id: Uuid, + pub subject: String, + pub created_at: DateTime, +} + +/// The 'version' of the password hashing scheme used for passwords when they +/// are migrated from Synapse to MAS. +/// This is version 1, as in the previous syn2mas script. +// TODO hardcoding version to `1` may not be correct long-term? +pub const MIGRATED_PASSWORD_VERSION: u16 = 1; + +/// List of all MAS tables that are written to by syn2mas. +pub const MAS_TABLES_AFFECTED_BY_MIGRATION: &[&str] = &[ + "users", + "user_passwords", + "user_emails", + "user_unsupported_third_party_ids", + "upstream_oauth_links", +]; + +/// Detect whether a syn2mas migration has started on the given database. +/// +/// Concretly, this checks for the presence of syn2mas restoration tables. +/// +/// Returns `true` if syn2mas has started, or `false` if it hasn't. +/// +/// # Errors +/// +/// Errors are returned under the following circumstances: +/// +/// - If any database error occurs whilst querying the database. +/// - If some, but not all, syn2mas restoration tables are present. (This +/// shouldn't be possible without syn2mas having been sabotaged!) +pub async fn is_syn2mas_in_progress(conn: &mut PgConnection) -> Result { + // Names of tables used for syn2mas resumption + // Must be `String`s, not just `&str`, for the query. + let restore_table_names = vec![ + "syn2mas_restore_constraints".to_owned(), + "syn2mas_restore_indices".to_owned(), + ]; + + let num_resumption_tables = query!( + r#" + SELECT 1 AS _dummy FROM pg_tables WHERE schemaname = current_schema + AND tablename = ANY($1) + "#, + &restore_table_names, + ) + .fetch_all(conn.as_mut()) + .await + .into_database("failed to query count of resumption tables")? + .len(); + + if num_resumption_tables == 0 { + Ok(false) + } else if num_resumption_tables == restore_table_names.len() { + Ok(true) + } else { + Err(Error::inconsistent( + "some, but not all, syn2mas resumption tables were found", + )) + } +} + +impl<'conn> MasWriter<'conn> { + /// Creates a new MAS writer. + /// + /// # Errors + /// + /// Errors are returned in the following conditions: + /// + /// - If the database connection experiences an error. + #[allow(clippy::missing_panics_doc)] // not real + #[tracing::instrument(skip_all)] + pub async fn new( + mut conn: LockedMasDatabase<'conn>, + mut writer_connections: Vec, + ) -> Result { + // Given that we don't have any concurrent transactions here, + // the READ COMMITTED isolation level is sufficient. + query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;") + .execute(conn.as_mut()) + .await + .into_database("begin MAS transaction")?; + + let syn2mas_started = is_syn2mas_in_progress(conn.as_mut()).await?; + + let indices_to_restore; + let constraints_to_restore; + + if syn2mas_started { + // We are resuming from a partially-done syn2mas migration + // We should reset the database so that we're starting from scratch. + warn!("Partial syn2mas migration has already been done; resetting."); + for table in MAS_TABLES_AFFECTED_BY_MIGRATION { + query(&format!("TRUNCATE syn2mas__{table};")) + .execute(conn.as_mut()) + .await + .into_database_with(|| format!("failed to truncate table syn2mas__{table}"))?; + } + + indices_to_restore = query_as!( + IndexDescription, + "SELECT table_name, name, definition FROM syn2mas_restore_indices ORDER BY order_key" + ) + .fetch_all(conn.as_mut()) + .await + .into_database("failed to get syn2mas restore data (index descriptions)")?; + constraints_to_restore = query_as!( + ConstraintDescription, + "SELECT table_name, name, definition FROM syn2mas_restore_constraints ORDER BY order_key" + ) + .fetch_all(conn.as_mut()) + .await + .into_database("failed to get syn2mas restore data (constraint descriptions)")?; + } else { + info!("Starting new syn2mas migration"); + + conn.as_mut() + .execute_many(include_str!("syn2mas_temporary_tables.sql")) + // We don't care about any query results + .try_collect::>() + .await + .into_database("could not create temporary tables")?; + + // Pause (temporarily drop) indices and constraints in order to improve + // performance of bulk data loading. + (indices_to_restore, constraints_to_restore) = + Self::pause_indices(conn.as_mut()).await?; + + // Persist these index and constraint definitions. + for IndexDescription { + name, + table_name, + definition, + } in &indices_to_restore + { + query!( + r#" + INSERT INTO syn2mas_restore_indices (name, table_name, definition) + VALUES ($1, $2, $3) + "#, + name, + table_name, + definition + ) + .execute(conn.as_mut()) + .await + .into_database("failed to save restore data (index)")?; + } + for ConstraintDescription { + name, + table_name, + definition, + } in &constraints_to_restore + { + query!( + r#" + INSERT INTO syn2mas_restore_constraints (name, table_name, definition) + VALUES ($1, $2, $3) + "#, + name, + table_name, + definition + ) + .execute(conn.as_mut()) + .await + .into_database("failed to save restore data (index)")?; + } + } + + query("COMMIT;") + .execute(conn.as_mut()) + .await + .into_database("begin MAS transaction")?; + + // Now after all the schema changes have been done, begin writer transactions + for writer_connection in &mut writer_connections { + query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;") + .execute(&mut *writer_connection) + .await + .into_database("begin MAS writer transaction")?; + } + + Ok(Self { + conn, + writer_pool: WriterConnectionPool::new(writer_connections), + indices_to_restore, + constraints_to_restore, + }) + } + + #[tracing::instrument(skip_all)] + async fn pause_indices( + conn: &mut PgConnection, + ) -> Result<(Vec, Vec), Error> { + let mut indices_to_restore = Vec::new(); + let mut constraints_to_restore = Vec::new(); + + for &unprefixed_table in MAS_TABLES_AFFECTED_BY_MIGRATION { + let table = format!("syn2mas__{unprefixed_table}"); + // First drop incoming foreign key constraints + for constraint in + constraint_pausing::describe_foreign_key_constraints_to_table(&mut *conn, &table) + .await? + { + constraint_pausing::drop_constraint(&mut *conn, &constraint).await?; + constraints_to_restore.push(constraint); + } + // After all incoming foreign key constraints have been removed, + // we can now drop internal constraints. + for constraint in + constraint_pausing::describe_constraints_on_table(&mut *conn, &table).await? + { + constraint_pausing::drop_constraint(&mut *conn, &constraint).await?; + constraints_to_restore.push(constraint); + } + // After all constraints have been removed, we can drop indices. + for index in constraint_pausing::describe_indices_on_table(&mut *conn, &table).await? { + constraint_pausing::drop_index(&mut *conn, &index).await?; + indices_to_restore.push(index); + } + } + + Ok((indices_to_restore, constraints_to_restore)) + } + + async fn restore_indices( + conn: &mut LockedMasDatabase<'_>, + indices_to_restore: &[IndexDescription], + constraints_to_restore: &[ConstraintDescription], + ) -> Result<(), Error> { + // First restore all indices. The order is not important as far as I know. + // However the indices are needed before constraints. + for index in indices_to_restore.iter().rev() { + constraint_pausing::restore_index(conn.as_mut(), index).await?; + } + // Then restore all constraints. + // The order here is the reverse of drop order, since some constraints may rely + // on other constraints to work. + for constraint in constraints_to_restore.iter().rev() { + constraint_pausing::restore_constraint(conn.as_mut(), constraint).await?; + } + Ok(()) + } + + /// Finish writing to the MAS database, flushing and committing all changes. + /// + /// # Errors + /// + /// Errors are returned in the following conditions: + /// + /// - If the database connection experiences an error. + #[tracing::instrument(skip_all)] + pub async fn finish(mut self) -> Result<(), Error> { + // Commit all writer transactions to the database. + self.writer_pool + .finish() + .await + .map_err(|errors| Error::Multiple(MultipleErrors::from(errors)))?; + + // Now all the data has been migrated, finish off by restoring indices and + // constraints! + + query("BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;") + .execute(self.conn.as_mut()) + .await + .into_database("begin MAS transaction")?; + + Self::restore_indices( + &mut self.conn, + &self.indices_to_restore, + &self.constraints_to_restore, + ) + .await?; + + self.conn + .as_mut() + .execute_many(include_str!("syn2mas_revert_temporary_tables.sql")) + // We don't care about any query results + .try_collect::>() + .await + .into_database("could not revert temporary tables")?; + + query("COMMIT;") + .execute(self.conn.as_mut()) + .await + .into_database("ending MAS transaction")?; + + self.conn + .unlock() + .await + .into_database("could not unlock MAS database")?; + + Ok(()) + } + + /// Write a batch of users to the database. + /// + /// # Errors + /// + /// Errors are returned in the following conditions: + /// + /// - If the database writer connection pool had an error. + #[allow(clippy::missing_panics_doc)] // not a real panic + #[tracing::instrument(skip_all, level = Level::DEBUG)] + pub fn write_users(&mut self, users: Vec) -> BoxFuture<'_, Result<(), Error>> { + self.writer_pool.spawn_with_connection(move |conn| Box::pin(async move { + // `UNNEST` is a fast way to do bulk inserts, as it lets us send multiple rows in one statement + // without having to change the statement SQL thus altering the query plan. + // See . + // In the future we could consider using sqlx's support for `PgCopyIn` / the `COPY FROM STDIN` statement, + // which is allegedly the best for insert performance, but is less simple to encode. + if users.is_empty() { + return Ok(()); + } + + let mut user_ids: Vec = Vec::with_capacity(users.len()); + let mut usernames: Vec = Vec::with_capacity(users.len()); + let mut created_ats: Vec> = Vec::with_capacity(users.len()); + let mut locked_ats: Vec>> = Vec::with_capacity(users.len()); + let mut can_request_admins: Vec = Vec::with_capacity(users.len()); + for MasNewUser { + user_id, + username, + created_at, + locked_at, + can_request_admin, + } in users + { + user_ids.push(user_id); + usernames.push(username); + created_ats.push(created_at); + locked_ats.push(locked_at); + can_request_admins.push(can_request_admin); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__users + (user_id, username, created_at, locked_at, can_request_admin) + SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[], $5::BOOL[]) + "#, + &user_ids[..], + &usernames[..], + &created_ats[..], + // We need to override the typing for arrays of optionals (sqlx limitation) + &locked_ats[..] as &[Option>], + &can_request_admins[..], + ).execute(&mut *conn).await.into_database("writing users to MAS")?; + + Ok(()) + })).boxed() + } + + /// Write a batch of user passwords to the database. + /// + /// # Errors + /// + /// Errors are returned in the following conditions: + /// + /// - If the database writer connection pool had an error. + #[allow(clippy::missing_panics_doc)] // not a real panic + #[tracing::instrument(skip_all, level = Level::DEBUG)] + pub fn write_passwords( + &mut self, + passwords: Vec, + ) -> BoxFuture<'_, Result<(), Error>> { + self.writer_pool.spawn_with_connection(move |conn| Box::pin(async move { + let mut user_password_ids: Vec = Vec::with_capacity(passwords.len()); + let mut user_ids: Vec = Vec::with_capacity(passwords.len()); + let mut hashed_passwords: Vec = Vec::with_capacity(passwords.len()); + let mut created_ats: Vec> = Vec::with_capacity(passwords.len()); + let mut versions: Vec = Vec::with_capacity(passwords.len()); + for MasNewUserPassword { + user_password_id, + user_id, + hashed_password, + created_at, + } in passwords + { + user_password_ids.push(user_password_id); + user_ids.push(user_id); + hashed_passwords.push(hashed_password); + created_ats.push(created_at); + versions.push(MIGRATED_PASSWORD_VERSION.into()); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__user_passwords + (user_password_id, user_id, hashed_password, created_at, version) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[]) + "#, + &user_password_ids[..], + &user_ids[..], + &hashed_passwords[..], + &created_ats[..], + &versions[..], + ).execute(&mut *conn).await.into_database("writing users to MAS")?; + + Ok(()) + })).boxed() + } + + #[tracing::instrument(skip_all, level = Level::DEBUG)] + pub fn write_email_threepids( + &mut self, + threepids: Vec, + ) -> BoxFuture<'_, Result<(), Error>> { + self.writer_pool.spawn_with_connection(move |conn| { + Box::pin(async move { + let mut user_email_ids: Vec = Vec::with_capacity(threepids.len()); + let mut user_ids: Vec = Vec::with_capacity(threepids.len()); + let mut emails: Vec = Vec::with_capacity(threepids.len()); + let mut created_ats: Vec> = Vec::with_capacity(threepids.len()); + + for MasNewEmailThreepid { + user_email_id, + user_id, + email, + created_at, + } in threepids + { + user_email_ids.push(user_email_id); + user_ids.push(user_id); + emails.push(email); + created_ats.push(created_at); + } + + // `confirmed_at` is going to get removed in a future MAS release, + // so just populate with `created_at` + sqlx::query!( + r#" + INSERT INTO syn2mas__user_emails + (user_email_id, user_id, email, created_at, confirmed_at) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[]) + "#, + &user_email_ids[..], + &user_ids[..], + &emails[..], + &created_ats[..], + ).execute(&mut *conn).await.into_database("writing emails to MAS")?; + + Ok(()) + }) + }).boxed() + } + + #[tracing::instrument(skip_all, level = Level::DEBUG)] + pub fn write_unsupported_threepids( + &mut self, + threepids: Vec, + ) -> BoxFuture<'_, Result<(), Error>> { + self.writer_pool.spawn_with_connection(move |conn| { + Box::pin(async move { + let mut user_ids: Vec = Vec::with_capacity(threepids.len()); + let mut mediums: Vec = Vec::with_capacity(threepids.len()); + let mut addresses: Vec = Vec::with_capacity(threepids.len()); + let mut created_ats: Vec> = Vec::with_capacity(threepids.len()); + + for MasNewUnsupportedThreepid { + user_id, + medium, + address, + created_at, + } in threepids + { + user_ids.push(user_id); + mediums.push(medium); + addresses.push(address); + created_ats.push(created_at); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__user_unsupported_third_party_ids + (user_id, medium, address, created_at) + SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[]) + "#, + &user_ids[..], + &mediums[..], + &addresses[..], + &created_ats[..], + ).execute(&mut *conn).await.into_database("writing unsupported threepids to MAS")?; + + Ok(()) + }) + }).boxed() + } + + #[tracing::instrument(skip_all, level = Level::DEBUG)] + pub fn write_upstream_oauth_links( + &mut self, + links: Vec, + ) -> BoxFuture<'_, Result<(), Error>> { + self.writer_pool.spawn_with_connection(move |conn| { + Box::pin(async move { + let mut link_ids: Vec = Vec::with_capacity(links.len()); + let mut user_ids: Vec = Vec::with_capacity(links.len()); + let mut upstream_provider_ids: Vec = Vec::with_capacity(links.len()); + let mut subjects: Vec = Vec::with_capacity(links.len()); + let mut created_ats: Vec> = Vec::with_capacity(links.len()); + + for MasNewUpstreamOauthLink { + link_id, + user_id, + upstream_provider_id, + subject, + created_at, + } in links + { + link_ids.push(link_id); + user_ids.push(user_id); + upstream_provider_ids.push(upstream_provider_id); + subjects.push(subject); + created_ats.push(created_at); + } + + sqlx::query!( + r#" + INSERT INTO syn2mas__upstream_oauth_links + (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[]) + "#, + &link_ids[..], + &user_ids[..], + &upstream_provider_ids[..], + &subjects[..], + &created_ats[..], + ).execute(&mut *conn).await.into_database("writing unsupported threepids to MAS")?; + + Ok(()) + }) + }).boxed() + } +} + +// How many entries to buffer at once, before writing a batch of rows to the +// database. TODO tune: didn't see that much difference between 4k and 64k +// (4k: 13.5~14, 64k: 12.5~13s — streaming the whole way would be better, +// especially for DB latency, but probably fiiine and also we won't be able to +// stream to two tables at once...) +const WRITE_BUFFER_BATCH_SIZE: usize = 4096; + +/// A function that can accept and flush buffers from a `MasWriteBuffer`. +/// Intended uses are the methods on `MasWriter` such as `write_users`. +type WriteBufferFlusher<'conn, T> = + for<'a> fn(&'a mut MasWriter<'conn>, Vec) -> BoxFuture<'a, Result<(), Error>>; + +/// A buffer for writing rows to the MAS database. +/// Generic over the type of rows. +/// +/// # Panics +/// +/// Panics if dropped before `finish()` has been called. +pub struct MasWriteBuffer<'conn, T> { + rows: Vec, + flusher: WriteBufferFlusher<'conn, T>, + finished: bool, +} + +impl<'conn, T> MasWriteBuffer<'conn, T> { + pub fn new(flusher: WriteBufferFlusher<'conn, T>) -> Self { + MasWriteBuffer { + rows: Vec::with_capacity(WRITE_BUFFER_BATCH_SIZE), + flusher, + finished: false, + } + } + + pub async fn finish(mut self, writer: &mut MasWriter<'conn>) -> Result<(), Error> { + self.finished = true; + self.flush(writer).await?; + Ok(()) + } + + pub async fn flush(&mut self, writer: &mut MasWriter<'conn>) -> Result<(), Error> { + if self.rows.is_empty() { + return Ok(()); + } + let rows = std::mem::take(&mut self.rows); + self.rows.reserve_exact(WRITE_BUFFER_BATCH_SIZE); + (self.flusher)(writer, rows).await?; + Ok(()) + } + + pub async fn write(&mut self, writer: &mut MasWriter<'conn>, row: T) -> Result<(), Error> { + self.rows.push(row); + if self.rows.len() >= WRITE_BUFFER_BATCH_SIZE { + self.flush(writer).await?; + } + Ok(()) + } +} + +impl Drop for MasWriteBuffer<'_, T> { + fn drop(&mut self) { + assert!(self.finished, "MasWriteBuffer dropped but not finished!"); + } +} + +#[cfg(test)] +mod test { + use std::collections::{BTreeMap, BTreeSet}; + + use chrono::DateTime; + use futures_util::TryStreamExt; + use serde::Serialize; + use sqlx::{Column, PgConnection, PgPool, Row}; + use uuid::Uuid; + + use crate::{ + mas_writer::{ + MasNewEmailThreepid, MasNewUnsupportedThreepid, MasNewUpstreamOauthLink, MasNewUser, + MasNewUserPassword, + }, + LockedMasDatabase, MasWriter, + }; + + /// A snapshot of a whole database + #[derive(Default, Serialize)] + #[serde(transparent)] + struct DatabaseSnapshot { + tables: BTreeMap, + } + + #[derive(Serialize)] + #[serde(transparent)] + struct TableSnapshot { + rows: BTreeSet, + } + + #[derive(PartialEq, Eq, PartialOrd, Ord, Serialize)] + #[serde(transparent)] + struct RowSnapshot { + columns_to_values: BTreeMap>, + } + + const SKIPPED_TABLES: &[&str] = &["_sqlx_migrations"]; + + /// Produces a serialisable snapshot of a database, usable for snapshot + /// testing + /// + /// For brevity, empty tables, as well as [`SKIPPED_TABLES`], will not be + /// included in the snapshot. + async fn snapshot_database(conn: &mut PgConnection) -> DatabaseSnapshot { + let mut out = DatabaseSnapshot::default(); + let table_names: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema();", + ) + .fetch_all(&mut *conn) + .await + .unwrap(); + + for table_name in table_names { + if SKIPPED_TABLES.contains(&table_name.as_str()) { + continue; + } + + let column_names: Vec = sqlx::query_scalar( + "SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = current_schema();" + ).bind(&table_name).fetch_all(&mut *conn).await.expect("failed to get column names for table for snapshotting"); + + let column_name_list = column_names + .iter() + // stringify all the values for simplicity + .map(|column_name| format!("{column_name}::TEXT AS \"{column_name}\"")) + .collect::>() + .join(", "); + + let table_rows = sqlx::query(&format!("SELECT {column_name_list} FROM {table_name};")) + .fetch(&mut *conn) + .map_ok(|row| { + let mut columns_to_values = BTreeMap::new(); + for (idx, column) in row.columns().iter().enumerate() { + columns_to_values.insert(column.name().to_owned(), row.get(idx)); + } + RowSnapshot { columns_to_values } + }) + .try_collect::>() + .await + .expect("failed to fetch rows from table for snapshotting"); + + if !table_rows.is_empty() { + out.tables + .insert(table_name, TableSnapshot { rows: table_rows }); + } + } + + out + } + + /// Make a snapshot assertion against the database. + macro_rules! assert_db_snapshot { + ($db: expr) => { + let db_snapshot = snapshot_database($db).await; + ::insta::assert_yaml_snapshot!(db_snapshot); + }; + } + + /// Runs some code with a `MasWriter`. + /// + /// The callback is responsible for `finish`ing the `MasWriter`. + async fn make_mas_writer<'conn>( + pool: &PgPool, + main_conn: &'conn mut PgConnection, + ) -> MasWriter<'conn> { + let mut writer_conns = Vec::new(); + for _ in 0..2 { + writer_conns.push( + pool.acquire() + .await + .expect("failed to acquire MasWriter writer connection") + .detach(), + ); + } + let locked_main_conn = LockedMasDatabase::try_new(main_conn) + .await + .expect("failed to lock MAS database") + .expect_left("MAS database is already locked"); + MasWriter::new(locked_main_conn, writer_conns) + .await + .expect("failed to construct MasWriter") + } + + /// Tests writing a single user, without a password. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_write_user(pool: PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: Uuid::from_u128(1u128), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } + + /// Tests writing a single user, with a password. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_write_user_with_password(pool: PgPool) { + const USER_ID: Uuid = Uuid::from_u128(1u128); + + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: USER_ID, + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + writer + .write_passwords(vec![MasNewUserPassword { + user_password_id: Uuid::from_u128(42u128), + user_id: USER_ID, + hashed_password: "$bcrypt$aaaaaaaaaaa".to_owned(), + created_at: DateTime::default(), + }]) + .await + .expect("failed to write password"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } + + /// Tests writing a single user, with an e-mail address associated. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_write_user_with_email(pool: PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: Uuid::from_u128(1u128), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + + writer + .write_email_threepids(vec![MasNewEmailThreepid { + user_email_id: Uuid::from_u128(2u128), + user_id: Uuid::from_u128(1u128), + email: "alice@example.org".to_owned(), + created_at: DateTime::default(), + }]) + .await + .expect("failed to write e-mail"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } + + /// Tests writing a single user, with a unsupported third-party ID + /// associated. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_write_user_with_unsupported_threepid(pool: PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: Uuid::from_u128(1u128), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + + writer + .write_unsupported_threepids(vec![MasNewUnsupportedThreepid { + user_id: Uuid::from_u128(1u128), + medium: "msisdn".to_owned(), + address: "441189998819991197253".to_owned(), + created_at: DateTime::default(), + }]) + .await + .expect("failed to write phone number (unsupported threepid)"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } + + /// Tests writing a single user, with a link to an upstream provider. + /// There needs to be an upstream provider in the database already — in the + /// real migration, this is done by running a provider sync first. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR", fixtures("upstream_provider"))] + async fn test_write_user_with_upstream_provider_link(pool: PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: Uuid::from_u128(1u128), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + + writer + .write_upstream_oauth_links(vec![MasNewUpstreamOauthLink { + user_id: Uuid::from_u128(1u128), + link_id: Uuid::from_u128(3u128), + upstream_provider_id: Uuid::from_u128(4u128), + subject: "12345.67890".to_owned(), + created_at: DateTime::default(), + }]) + .await + .expect("failed to write link"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } +} diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap new file mode 100644 index 000000000..62d12ad5a --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap @@ -0,0 +1,11 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap new file mode 100644 index 000000000..6d0e5b6a9 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_email.snap @@ -0,0 +1,17 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +user_emails: + - confirmed_at: "1970-01-01 00:00:00+00" + created_at: "1970-01-01 00:00:00+00" + email: alice@example.org + user_email_id: 00000000-0000-0000-0000-000000000002 + user_id: 00000000-0000-0000-0000-000000000001 +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap new file mode 100644 index 000000000..13f8db6a8 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap @@ -0,0 +1,18 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +user_passwords: + - created_at: "1970-01-01 00:00:00+00" + hashed_password: $bcrypt$aaaaaaaaaaa + upgraded_from_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + user_password_id: 00000000-0000-0000-0000-00000000002a + version: "1" +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap new file mode 100644 index 000000000..79805555a --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_unsupported_threepid.snap @@ -0,0 +1,16 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +user_unsupported_third_party_ids: + - address: "441189998819991197253" + created_at: "1970-01-01 00:00:00+00" + medium: msisdn + user_id: 00000000-0000-0000-0000-000000000001 +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap new file mode 100644 index 000000000..76393c6ca --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap @@ -0,0 +1,42 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +upstream_oauth_links: + - created_at: "1970-01-01 00:00:00+00" + human_account_name: ~ + subject: "12345.67890" + upstream_oauth_link_id: 00000000-0000-0000-0000-000000000003 + upstream_oauth_provider_id: 00000000-0000-0000-0000-000000000004 + user_id: 00000000-0000-0000-0000-000000000001 +upstream_oauth_providers: + - additional_parameters: ~ + authorization_endpoint_override: ~ + brand_name: ~ + claims_imports: "{}" + client_id: someClientId + created_at: "2011-12-13 14:15:16+00" + disabled_at: ~ + discovery_mode: oidc + encrypted_client_secret: ~ + fetch_userinfo: "false" + human_name: ~ + id_token_signed_response_alg: RS256 + issuer: ~ + jwks_uri_override: ~ + pkce_mode: auto + response_mode: query + scope: openid + token_endpoint_auth_method: client_secret_basic + token_endpoint_override: ~ + token_endpoint_signing_alg: ~ + upstream_oauth_provider_id: 00000000-0000-0000-0000-000000000004 + userinfo_endpoint_override: ~ + userinfo_signed_response_alg: ~ +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice diff --git a/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql b/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql new file mode 100644 index 000000000..ee27b6ba8 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql @@ -0,0 +1,15 @@ +-- Copyright 2024 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- This script should revert what `syn2mas_temporary_tables.sql` does. + +DROP TABLE syn2mas_restore_constraints; +DROP TABLE syn2mas_restore_indices; + +ALTER TABLE syn2mas__users RENAME TO users; +ALTER TABLE syn2mas__user_passwords RENAME TO user_passwords; +ALTER TABLE syn2mas__user_emails RENAME TO user_emails; +ALTER TABLE syn2mas__user_unsupported_third_party_ids RENAME TO user_unsupported_third_party_ids; +ALTER TABLE syn2mas__upstream_oauth_links RENAME TO upstream_oauth_links; diff --git a/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql b/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql new file mode 100644 index 000000000..4d07d2469 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql @@ -0,0 +1,44 @@ +-- Copyright 2024 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + + +-- # syn2mas Temporary Tables +-- This file takes a MAS database and: +-- +-- 1. creates temporary tables used by syn2mas for storing restore data +-- 2. renames important tables with the `syn2mas__` prefix, to prevent +-- running MAS instances from having any opportunity to see or modify +-- the partial data in the database, especially whilst it is not protected +-- by constraints. +-- +-- All changes in this file must be reverted by `syn2mas_revert_temporary_tables.sql` +-- in the same directory. + +-- corresponds to `ConstraintDescription` +CREATE TABLE syn2mas_restore_constraints ( + -- synthetic auto-incrementing ID so we can load these in order + order_key SERIAL NOT NULL PRIMARY KEY, + + table_name TEXT NOT NULL, + name TEXT NOT NULL, + definition TEXT NOT NULL +); + +-- corresponds to `IndexDescription` +CREATE TABLE syn2mas_restore_indices ( + -- synthetic auto-incrementing ID so we can load these in order + order_key SERIAL NOT NULL PRIMARY KEY, + + table_name TEXT NOT NULL, + name TEXT NOT NULL, + definition TEXT NOT NULL +); + +-- Now we rename all tables that we touch during the migration. +ALTER TABLE users RENAME TO syn2mas__users; +ALTER TABLE user_passwords RENAME TO syn2mas__user_passwords; +ALTER TABLE user_emails RENAME TO syn2mas__user_emails; +ALTER TABLE user_unsupported_third_party_ids RENAME TO syn2mas__user_unsupported_third_party_ids; +ALTER TABLE upstream_oauth_links RENAME TO syn2mas__upstream_oauth_links; diff --git a/crates/syn2mas/src/migration.rs b/crates/syn2mas/src/migration.rs new file mode 100644 index 000000000..250db90f2 --- /dev/null +++ b/crates/syn2mas/src/migration.rs @@ -0,0 +1,352 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! # Migration +//! +//! This module provides the high-level logic for performing the Synapse-to-MAS +//! database migration. +//! +//! This module does not implement any of the safety checks that should be run +//! *before* the migration. + +use std::{collections::HashMap, pin::pin}; + +use chrono::{DateTime, Utc}; +use compact_str::CompactString; +use futures_util::StreamExt as _; +use rand::RngCore; +use thiserror::Error; +use thiserror_ext::ContextInto; +use tracing::Level; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{ + mas_writer::{ + self, MasNewEmailThreepid, MasNewUnsupportedThreepid, MasNewUpstreamOauthLink, MasNewUser, + MasNewUserPassword, MasWriteBuffer, MasWriter, + }, + synapse_reader::{ + self, ExtractLocalpartError, FullUserId, SynapseExternalId, SynapseThreepid, SynapseUser, + }, + SynapseReader, +}; + +#[derive(Debug, Error, ContextInto)] +pub enum Error { + #[error("error when reading synapse DB ({context}): {source}")] + Synapse { + source: synapse_reader::Error, + context: String, + }, + #[error("error when writing to MAS DB ({context}): {source}")] + Mas { + source: mas_writer::Error, + context: String, + }, + #[error("failed to extract localpart of {user:?}: {source}")] + ExtractLocalpart { + source: ExtractLocalpartError, + user: FullUserId, + }, + #[error("user {user} was not found for migration but a row in {table} was found for them")] + MissingUserFromDependentTable { table: String, user: FullUserId }, + #[error("missing a mapping for the auth provider with ID {synapse_id:?} (used by {user} and maybe other users)")] + MissingAuthProviderMapping { + /// `auth_provider` ID of the provider in Synapse, for which we have no + /// mapping + synapse_id: String, + /// a user that is using this auth provider + user: FullUserId, + }, +} + +struct UsersMigrated { + /// Lookup table from user localpart to that user's UUID in MAS. + user_localparts_to_uuid: HashMap, +} + +/// Performs a migration from Synapse's database to MAS' database. +/// +/// # Panics +/// +/// - If there are more than `usize::MAX` users +/// +/// # Errors +/// +/// Errors are returned under the following circumstances: +/// +/// - An underlying database access error, either to MAS or to Synapse. +/// - Invalid data in the Synapse database. +#[allow(clippy::implicit_hasher)] +pub async fn migrate( + synapse: &mut SynapseReader<'_>, + mas: &mut MasWriter<'_>, + server_name: &str, + rng: &mut impl RngCore, + provider_id_mapping: &HashMap, +) -> Result<(), Error> { + let counts = synapse.count_rows().await.into_synapse("counting users")?; + + let migrated_users = migrate_users( + synapse, + mas, + counts + .users + .try_into() + .expect("More than usize::MAX users — wow!"), + server_name, + rng, + ) + .await?; + + migrate_threepids( + synapse, + mas, + server_name, + rng, + &migrated_users.user_localparts_to_uuid, + ) + .await?; + + migrate_external_ids( + synapse, + mas, + server_name, + rng, + &migrated_users.user_localparts_to_uuid, + provider_id_mapping, + ) + .await?; + + Ok(()) +} + +#[tracing::instrument(skip_all, level = Level::INFO)] +async fn migrate_users( + synapse: &mut SynapseReader<'_>, + mas: &mut MasWriter<'_>, + user_count_hint: usize, + server_name: &str, + rng: &mut impl RngCore, +) -> Result { + let mut user_buffer = MasWriteBuffer::new(MasWriter::write_users); + let mut password_buffer = MasWriteBuffer::new(MasWriter::write_passwords); + let mut users_stream = pin!(synapse.read_users()); + // TODO is 1:1 capacity enough for a hashmap? + let mut user_localparts_to_uuid = HashMap::with_capacity(user_count_hint); + + while let Some(user_res) = users_stream.next().await { + let user = user_res.into_synapse("reading user")?; + let (mas_user, mas_password_opt) = transform_user(&user, server_name, rng)?; + + user_localparts_to_uuid.insert(CompactString::new(&mas_user.username), mas_user.user_id); + + user_buffer + .write(mas, mas_user) + .await + .into_mas("writing user")?; + + if let Some(mas_password) = mas_password_opt { + password_buffer + .write(mas, mas_password) + .await + .into_mas("writing password")?; + } + } + + user_buffer.finish(mas).await.into_mas("writing users")?; + password_buffer + .finish(mas) + .await + .into_mas("writing passwords")?; + + Ok(UsersMigrated { + user_localparts_to_uuid, + }) +} + +#[tracing::instrument(skip_all, level = Level::INFO)] +async fn migrate_threepids( + synapse: &mut SynapseReader<'_>, + mas: &mut MasWriter<'_>, + server_name: &str, + rng: &mut impl RngCore, + user_localparts_to_uuid: &HashMap, +) -> Result<(), Error> { + let mut email_buffer = MasWriteBuffer::new(MasWriter::write_email_threepids); + let mut unsupported_buffer = MasWriteBuffer::new(MasWriter::write_unsupported_threepids); + let mut users_stream = pin!(synapse.read_threepids()); + + while let Some(threepid_res) = users_stream.next().await { + let SynapseThreepid { + user_id: synapse_user_id, + medium, + address, + added_at, + } = threepid_res.into_synapse("reading threepid")?; + let created_at: DateTime = added_at.into(); + + let username = synapse_user_id + .extract_localpart(server_name) + .into_extract_localpart(synapse_user_id.clone())? + .to_owned(); + let Some(user_id) = user_localparts_to_uuid.get(username.as_str()).copied() else { + return Err(Error::MissingUserFromDependentTable { + table: "user_threepids".to_owned(), + user: synapse_user_id, + }); + }; + + if medium == "email" { + email_buffer + .write( + mas, + MasNewEmailThreepid { + user_id, + user_email_id: Uuid::from(Ulid::from_datetime_with_source( + created_at.into(), + rng, + )), + email: address, + created_at, + }, + ) + .await + .into_mas("writing email")?; + } else { + unsupported_buffer + .write( + mas, + MasNewUnsupportedThreepid { + user_id, + medium, + address, + created_at, + }, + ) + .await + .into_mas("writing unsupported threepid")?; + } + } + + email_buffer + .finish(mas) + .await + .into_mas("writing email threepids")?; + unsupported_buffer + .finish(mas) + .await + .into_mas("writing unsupported threepids")?; + + Ok(()) +} + +/// # Parameters +/// +/// - `provider_id_mapping`: mapping from Synapse `auth_provider` ID to UUID of +/// the upstream provider in MAS. +#[tracing::instrument(skip_all, level = Level::INFO)] +async fn migrate_external_ids( + synapse: &mut SynapseReader<'_>, + mas: &mut MasWriter<'_>, + server_name: &str, + rng: &mut impl RngCore, + user_localparts_to_uuid: &HashMap, + provider_id_mapping: &HashMap, +) -> Result<(), Error> { + let mut write_buffer = MasWriteBuffer::new(MasWriter::write_upstream_oauth_links); + let mut extids_stream = pin!(synapse.read_user_external_ids()); + + while let Some(extid_res) = extids_stream.next().await { + let SynapseExternalId { + user_id: synapse_user_id, + auth_provider, + external_id: subject, + } = extid_res.into_synapse("reading external ID")?; + let username = synapse_user_id + .extract_localpart(server_name) + .into_extract_localpart(synapse_user_id.clone())? + .to_owned(); + let Some(user_id) = user_localparts_to_uuid.get(username.as_str()).copied() else { + return Err(Error::MissingUserFromDependentTable { + table: "user_external_ids".to_owned(), + user: synapse_user_id, + }); + }; + + let Some(&upstream_provider_id) = provider_id_mapping.get(&auth_provider) else { + return Err(Error::MissingAuthProviderMapping { + synapse_id: auth_provider, + user: synapse_user_id, + }); + }; + + // To save having to store user creation times, extract it from the ULID + // This gives millisecond precision — good enough. + let user_created_ts = Ulid::from(user_id).datetime(); + + let link_id: Uuid = Ulid::from_datetime_with_source(user_created_ts, rng).into(); + + write_buffer + .write( + mas, + MasNewUpstreamOauthLink { + link_id, + user_id, + upstream_provider_id, + subject, + created_at: user_created_ts.into(), + }, + ) + .await + .into_mas("failed to write upstream link")?; + } + + write_buffer + .finish(mas) + .await + .into_mas("writing threepids")?; + + Ok(()) +} + +fn transform_user( + user: &SynapseUser, + server_name: &str, + rng: &mut impl RngCore, +) -> Result<(MasNewUser, Option), Error> { + let username = user + .name + .extract_localpart(server_name) + .into_extract_localpart(user.name.clone())? + .to_owned(); + + let new_user = MasNewUser { + user_id: Uuid::from(Ulid::from_datetime_with_source( + DateTime::::from(user.creation_ts).into(), + rng, + )), + username, + created_at: user.creation_ts.into(), + locked_at: bool::from(user.deactivated).then_some(user.creation_ts.into()), + can_request_admin: bool::from(user.admin), + }; + + let mas_password = user + .password_hash + .clone() + .map(|password_hash| MasNewUserPassword { + user_password_id: Uuid::from(Ulid::from_datetime_with_source( + DateTime::::from(user.creation_ts).into(), + rng, + )), + user_id: new_user.user_id, + hashed_password: password_hash, + created_at: new_user.created_at, + }); + + Ok((new_user, mas_password)) +} diff --git a/crates/syn2mas/src/synapse_reader/checks.rs b/crates/syn2mas/src/synapse_reader/checks.rs new file mode 100644 index 000000000..71d4375b7 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/checks.rs @@ -0,0 +1,306 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! # Synapse Checks +//! +//! This module provides safety checks to run against a Synapse database before +//! running the Synapse-to-MAS migration. + +use figment::Figment; +use mas_config::{ + BrandingConfig, CaptchaConfig, ConfigurationSection, ConfigurationSectionExt, MatrixConfig, + PasswordAlgorithm, PasswordsConfig, UpstreamOAuth2Config, +}; +use sqlx::{prelude::FromRow, query_as, query_scalar, PgConnection}; +use thiserror::Error; + +use super::config::Config; +use crate::mas_writer::MIGRATED_PASSWORD_VERSION; + +#[derive(Debug, Error)] +pub enum Error { + #[error("query failed: {0}")] + Sqlx(#[from] sqlx::Error), + + #[error("failed to load MAS config: {0}")] + MasConfig(#[from] figment::Error), + + #[error("failed to load MAS password config: {0}")] + MasPasswordConfig(#[source] anyhow::Error), +} + +/// An error found whilst checking the Synapse database, that should block a +/// migration. +#[derive(Debug, Error)] +pub enum CheckError { + #[error("MAS config is missing a password hashing scheme with version '1'")] + MissingPasswordScheme, + + #[error("Password scheme version '1' in the MAS config must use the Bcrypt algorithm, so that Synapse passwords can be imported and will be compatible.")] + PasswordSchemeNotBcrypt, + + #[error("Password scheme version '1' in the MAS config must have the same secret as the `pepper` value from Synapse, so that Synapse passwords can be imported and will be compatible.")] + PasswordSchemeWrongPepper, + + #[error("Synapse database contains {num_guests} guests which aren't supported by MAS. See https://github.com/element-hq/matrix-authentication-service/issues/1445")] + GuestsInDatabase { num_guests: i64 }, + + #[error("Guest support is enabled in the Synapse configuration. Guests aren't supported by MAS, but if you don't have any then you could disable the option. See https://github.com/element-hq/matrix-authentication-service/issues/1445")] + GuestsEnabled, + + #[error("Synapse database contains {num_non_email_3pids} non-email 3PIDs (probably phone numbers), which are not supported by MAS.")] + NonEmailThreepidsInDatabase { num_non_email_3pids: i64 }, + + #[error( + "Synapse config has `enable_3pid_changes` explicitly enabled, which must be disabled or removed." + )] + ThreepidChangesEnabled, + + #[error("Synapse config has `login_via_existing_session.enabled` set to true, which must be disabled.")] + LoginViaExistingSessionEnabled, + + #[error("MAS configuration has the wrong `matrix.homeserver` set ({mas:?}), it should match Synapse's `server_name` ({synapse:?})")] + ServerNameMismatch { synapse: String, mas: String }, + + #[error("Synapse database contains {num_users} users associated to the OpenID Connect or OAuth2 provider '{provider}' but the Synapse configuration does not contain this provider.")] + SynapseMissingOAuthProvider { provider: String, num_users: i64 }, + + #[error("Synapse config contains an OpenID Connect or OAuth2 provider '{provider}' (issuer: {issuer:?}) used by {num_users} users which must also be configured in the MAS configuration as an upstream provider.")] + MasMissingOAuthProvider { + provider: String, + issuer: String, + num_users: i64, + }, +} + +/// A potential hazard found whilst checking the Synapse database, that should +/// be presented to the operator to check they are aware of a caveat before +/// proceeding with the migration. +#[derive(Debug, Error)] +pub enum CheckWarning { + #[error("Synapse config contains OIDC auth configuration (issuer: {issuer:?}) which will need to be manually mapped to an upstream OpenID Connect Provider during migration.")] + UpstreamOidcProvider { issuer: String }, + + #[error("Synapse config contains {0} auth configuration which will need to be manually mapped as an upstream OAuth 2.0 provider during migration.")] + ExternalAuthSystem(&'static str), + + #[error("Synapse config has registration enabled. This must be disabled after migration before bringing Synapse back online.")] + DisableRegistrationAfterMigration, + + #[error("Synapse config has `user_consent` enabled. This should be disabled after migration.")] + DisableUserConsentAfterMigration, + + #[error("Synapse config has `user_consent` enabled but MAS has not been configured with terms of service. You may wish to set up a `tos_uri` in your MAS branding configuration to replace the user consent.")] + ShouldPortUserConsentAsTerms, + + #[error("Synapse config has a registration CAPTCHA enabled, but no CAPTCHA has been configured in MAS. You may wish to manually configure this.")] + ShouldPortRegistrationCaptcha, +} + +/// Check that the Synapse configuration is sane for migration. +#[must_use] +pub fn synapse_config_check(synapse_config: &Config) -> (Vec, Vec) { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + if synapse_config.enable_registration { + warnings.push(CheckWarning::DisableRegistrationAfterMigration); + } + if synapse_config.user_consent { + warnings.push(CheckWarning::DisableUserConsentAfterMigration); + } + + // TODO check the settings directly against the MAS settings + for provider in synapse_config.all_oidc_providers().values() { + if let Some(ref issuer) = provider.issuer { + warnings.push(CheckWarning::UpstreamOidcProvider { + issuer: issuer.clone(), + }); + } + } + + // TODO provide guidance on migrating these + if synapse_config.cas_config.enabled { + warnings.push(CheckWarning::ExternalAuthSystem("CAS")); + } + if synapse_config.saml2_config.enabled { + warnings.push(CheckWarning::ExternalAuthSystem("SAML2")); + } + if synapse_config.jwt_config.enabled { + warnings.push(CheckWarning::ExternalAuthSystem("JWT")); + } + + // TODO provide guidance on migrating these + if synapse_config.password_config.enabled && !synapse_config.password_config.localdb_enabled { + warnings.push(CheckWarning::ExternalAuthSystem( + "non-standard password provider plugin", + )); + } + + if synapse_config.enable_3pid_changes { + errors.push(CheckError::ThreepidChangesEnabled); + } + + if synapse_config.login_via_existing_session.enabled { + errors.push(CheckError::LoginViaExistingSessionEnabled); + } + + (warnings, errors) +} + +/// Check that the given Synapse configuration is sane for migration to a MAS +/// with the given MAS configuration. +/// +/// # Errors +/// +/// - If any necessary section of MAS config cannot be parsed. +/// - If the MAS password configuration (including any necessary secrets) can't +/// be loaded. +pub async fn synapse_config_check_against_mas_config( + synapse: &Config, + mas: &Figment, +) -> Result<(Vec, Vec), Error> { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + let mas_passwords = PasswordsConfig::extract_or_default(mas)?; + let mas_password_schemes = mas_passwords + .load() + .await + .map_err(Error::MasPasswordConfig)?; + + let mas_matrix = MatrixConfig::extract(mas)?; + + // Look for the MAS password hashing scheme that will be used for imported + // Synapse passwords, then check the configuration matches so that Synapse + // passwords will be compatible with MAS. + if let Some((_, algorithm, _, secret)) = mas_password_schemes + .iter() + .find(|(version, _, _, _)| *version == MIGRATED_PASSWORD_VERSION) + { + if algorithm != &PasswordAlgorithm::Bcrypt { + errors.push(CheckError::PasswordSchemeNotBcrypt); + } + + let synapse_pepper = synapse + .password_config + .pepper + .as_ref() + .map(String::as_bytes); + if secret.as_deref() != synapse_pepper { + errors.push(CheckError::PasswordSchemeWrongPepper); + } + } else { + errors.push(CheckError::MissingPasswordScheme); + } + + if synapse.allow_guest_access { + errors.push(CheckError::GuestsEnabled); + } + + if synapse.server_name != mas_matrix.homeserver { + errors.push(CheckError::ServerNameMismatch { + synapse: synapse.server_name.clone(), + mas: mas_matrix.homeserver.clone(), + }); + } + + let mas_captcha = CaptchaConfig::extract_or_default(mas)?; + if synapse.enable_registration_captcha && mas_captcha.service.is_none() { + warnings.push(CheckWarning::ShouldPortRegistrationCaptcha); + } + + let mas_branding = BrandingConfig::extract_or_default(mas)?; + if synapse.user_consent && mas_branding.tos_uri.is_none() { + warnings.push(CheckWarning::ShouldPortUserConsentAsTerms); + } + + Ok((warnings, errors)) +} + +/// Check that the Synapse database is sane for migration. Returns a list of +/// warnings and errors. +/// +/// # Errors +/// +/// - If there is some database connection error, or the given database is not a +/// Synapse database. +/// - If the OAuth2 section of the MAS configuration could not be parsed. +#[tracing::instrument(skip_all)] +pub async fn synapse_database_check( + synapse_connection: &mut PgConnection, + synapse: &Config, + mas: &Figment, +) -> Result<(Vec, Vec), Error> { + #[derive(FromRow)] + struct UpstreamOAuthProvider { + auth_provider: String, + num_users: i64, + } + + let mut errors = Vec::new(); + let warnings = Vec::new(); + + let num_guests: i64 = query_scalar("SELECT COUNT(1) FROM users WHERE is_guest <> 0") + .fetch_one(&mut *synapse_connection) + .await?; + if num_guests > 0 { + errors.push(CheckError::GuestsInDatabase { num_guests }); + } + + let num_non_email_3pids: i64 = + query_scalar("SELECT COUNT(1) FROM user_threepids WHERE medium <> 'email'") + .fetch_one(&mut *synapse_connection) + .await?; + if num_non_email_3pids > 0 { + errors.push(CheckError::NonEmailThreepidsInDatabase { + num_non_email_3pids, + }); + } + + let oauth_provider_user_counts = query_as::<_, UpstreamOAuthProvider>( + " + SELECT auth_provider, COUNT(*) AS num_users + FROM user_external_ids + GROUP BY auth_provider + ORDER BY auth_provider + ", + ) + .fetch_all(&mut *synapse_connection) + .await?; + if !oauth_provider_user_counts.is_empty() { + let syn_oauth2 = synapse.all_oidc_providers(); + let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas)?; + for row in oauth_provider_user_counts { + let matching_syn = syn_oauth2.get(&row.auth_provider); + + let Some(matching_syn) = matching_syn else { + errors.push(CheckError::SynapseMissingOAuthProvider { + provider: row.auth_provider, + num_users: row.num_users, + }); + continue; + }; + + // Matching by `synapse_idp_id` is the same as what we'll do for the migration + let matching_mas = mas_oauth2.providers.iter().find(|mas_provider| { + mas_provider.synapse_idp_id.as_ref() == Some(&row.auth_provider) + }); + + if matching_mas.is_none() { + errors.push(CheckError::MasMissingOAuthProvider { + provider: row.auth_provider, + issuer: matching_syn + .issuer + .clone() + .unwrap_or("".to_owned()), + num_users: row.num_users, + }); + } + } + } + + Ok((warnings, errors)) +} diff --git a/crates/syn2mas/src/synapse_reader/config.rs b/crates/syn2mas/src/synapse_reader/config.rs new file mode 100644 index 000000000..0dca5b6e7 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/config.rs @@ -0,0 +1,297 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::collections::BTreeMap; + +use camino::Utf8PathBuf; +use figment::providers::{Format, Yaml}; +use serde::Deserialize; +use sqlx::postgres::PgConnectOptions; + +/// The root of a Synapse configuration. +/// This struct only includes fields which the Synapse-to-MAS migration is +/// interested in. +/// +/// See: +#[derive(Deserialize)] +#[allow(clippy::struct_excessive_bools)] +pub struct Config { + pub database: DatabaseSection, + pub password_config: PasswordSection, + + #[serde(default)] + pub allow_guest_access: bool, + + #[serde(default)] + pub enable_registration: bool, + + #[serde(default)] + pub enable_registration_captcha: bool, + + /// Normally this defaults to true, but when MAS integration is enabled in + /// Synapse it defaults to false. + #[serde(default)] + pub enable_3pid_changes: bool, + + #[serde(default)] + pub user_consent: bool, + + #[serde(default)] + pub registrations_require_3pid: Vec, + + #[serde(default)] + pub registration_requires_token: bool, + + pub registration_shared_secret: Option, + + #[serde(default)] + pub login_via_existing_session: EnableableSection, + + #[serde(default)] + pub cas_config: EnableableSection, + + #[serde(default)] + pub saml2_config: EnableableSection, + + #[serde(default)] + pub jwt_config: EnableableSection, + + #[serde(default)] + pub oidc_config: Option, + + #[serde(default)] + pub oidc_providers: Vec, + + pub server_name: String, +} + +impl Config { + /// Load a Synapse configuration from the given list of configuration files. + /// + /// # Errors + /// + /// - If there is a problem reading any of the files. + /// - If the configuration is not valid. + pub fn load(files: &[Utf8PathBuf]) -> Result { + let mut figment = figment::Figment::new(); + for file in files { + // TODO this is not exactly correct behaviour — Synapse does not merge anything + // other than the top level dict. + // https://github.com/element-hq/matrix-authentication-service/pull/3805#discussion_r1922680825 + // https://github.com/element-hq/synapse/blob/develop/synapse/config/_base.py?rgh-link-date=2025-01-20T17%3A02%3A56Z#L870 + figment = figment.merge(Yaml::file(file)); + } + figment.extract::() + } + + /// Returns a map of all OIDC providers from the Synapse configuration. + /// + /// The keys are the `auth_provider` IDs as they would have been stored in + /// Synapse's database. + /// + /// These are compatible with the `synapse_idp_id` field of + /// [`mas_config::UpstreamOAuth2Provider`]. + #[must_use] + pub fn all_oidc_providers(&self) -> BTreeMap { + let mut out = BTreeMap::new(); + + if let Some(provider) = &self.oidc_config { + if provider.issuer.is_some() { + // The legacy configuration has an implied IdP ID of `oidc`. + out.insert("oidc".to_owned(), provider.clone()); + } + } + + for provider in &self.oidc_providers { + if let Some(idp_id) = &provider.idp_id { + // Synapse internally prefixes the IdP IDs with `oidc-`. + out.insert(format!("oidc-{idp_id}"), provider.clone()); + } + } + + out + } +} + +/// The `database` section of the Synapse configuration. +/// +/// See: +#[derive(Deserialize)] +pub struct DatabaseSection { + /// Expecting `psycopg2` for Postgres or `sqlite3` for `SQLite3`, but may be + /// an arbitrary string and future versions of Synapse may support other + /// database drivers, e.g. psycopg3. + pub name: String, + #[serde(default)] + pub args: DatabaseArgsSuboption, +} + +/// The database driver name for Synapse when it is using Postgres via psycopg2. +pub const SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2: &str = "psycopg2"; +/// The database driver name for Synapse when it is using SQLite 3. +pub const SYNAPSE_DATABASE_DRIVER_NAME_SQLITE3: &str = "sqlite3"; + +impl DatabaseSection { + /// Process the configuration into Postgres connection options. + /// + /// Environment variables and libpq defaults will be used as fallback for + /// any missing values; this should match what Synapse does. + /// But note that if syn2mas is not run in the same context (host, user, + /// environment variables) as Synapse normally runs, then the connection + /// options may not be valid. + /// + /// Returns `None` if this database configuration is not configured for + /// Postgres. + #[must_use] + pub fn to_sqlx_postgres(&self) -> Option { + if self.name != SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2 { + return None; + } + let mut opts = PgConnectOptions::new().application_name("syn2mas-synapse"); + + if let Some(host) = &self.args.host { + opts = opts.host(host); + } + if let Some(port) = self.args.port { + opts = opts.port(port); + } + if let Some(dbname) = &self.args.dbname { + opts = opts.database(dbname); + } + if let Some(user) = &self.args.user { + opts = opts.username(user); + } + if let Some(password) = &self.args.password { + opts = opts.password(password); + } + + Some(opts) + } +} + +/// The `args` suboption of the `database` section of the Synapse configuration. +/// This struct assumes Postgres is in use and does not represent fields used by +/// SQLite. +#[derive(Deserialize, Default)] +pub struct DatabaseArgsSuboption { + pub user: Option, + pub password: Option, + pub dbname: Option, + pub host: Option, + pub port: Option, +} + +/// The `password_config` section of the Synapse configuration. +/// +/// See: +#[derive(Deserialize)] +pub struct PasswordSection { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_true")] + pub localdb_enabled: bool, + pub pepper: Option, +} + +impl Default for PasswordSection { + fn default() -> Self { + PasswordSection { + enabled: true, + localdb_enabled: true, + pepper: None, + } + } +} + +/// A section that we only care about whether it's enabled or not, but is not +/// enabled by default. +#[derive(Default, Deserialize)] +pub struct EnableableSection { + #[serde(default)] + pub enabled: bool, +} + +#[derive(Clone, Deserialize)] +pub struct OidcProvider { + /// At least for `oidc_config`, if the dict is present but left empty then + /// the config should be ignored, so this field must be optional. + pub issuer: Option, + + /// Required, except for the old `oidc_config` where this is implied to be + /// "oidc". + pub idp_id: Option, +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod test { + use sqlx::postgres::PgConnectOptions; + + use super::{DatabaseArgsSuboption, DatabaseSection}; + + #[test] + fn test_to_sqlx_postgres() { + #[track_caller] + #[allow(clippy::needless_pass_by_value)] + fn assert_eq_options(config: DatabaseSection, uri: &str) { + let config_connect_options = config + .to_sqlx_postgres() + .expect("no connection options generated by config"); + let uri_connect_options: PgConnectOptions = uri + .parse() + .expect("example URI did not parse as PgConnectionOptions"); + + assert_eq!( + config_connect_options.get_host(), + uri_connect_options.get_host() + ); + assert_eq!( + config_connect_options.get_port(), + uri_connect_options.get_port() + ); + assert_eq!( + config_connect_options.get_username(), + uri_connect_options.get_username() + ); + // The password is not public so we can't assert it. But that's hopefully fine. + assert_eq!( + config_connect_options.get_database(), + uri_connect_options.get_database() + ); + } + + // SQLite configs are not accepted + assert!(DatabaseSection { + name: "sqlite3".to_owned(), + args: DatabaseArgsSuboption::default(), + } + .to_sqlx_postgres() + .is_none()); + + assert_eq_options( + DatabaseSection { + name: "psycopg2".to_owned(), + args: DatabaseArgsSuboption::default(), + }, + "postgresql:///", + ); + assert_eq_options( + DatabaseSection { + name: "psycopg2".to_owned(), + args: DatabaseArgsSuboption { + user: Some("synapse_user".to_owned()), + password: Some("verysecret".to_owned()), + dbname: Some("synapse_db".to_owned()), + host: Some("synapse-db.example.com".to_owned()), + port: Some(42), + }, + }, + "postgresql://synapse_user:verysecret@synapse-db.example.com:42/synapse_db", + ); + } +} diff --git a/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql new file mode 100644 index 000000000..5a00cebb5 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/fixtures/external_ids_alice.sql @@ -0,0 +1,12 @@ +INSERT INTO user_external_ids + ( + user_id, + auth_provider, + external_id + ) + VALUES + ( + '@alice:example.com', + 'oidc-raasu', + '871.syn30' + ); diff --git a/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql new file mode 100644 index 000000000..526c00c2c --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/fixtures/threepids_alice.sql @@ -0,0 +1,23 @@ +INSERT INTO user_threepids + ( + user_id, + medium, + address, + validated_at, + added_at + ) + VALUES + ( + '@alice:example.com', + 'email', + 'alice@example.com', + 1554228492026, + 1554228549014 + ), + ( + '@alice:example.com', + 'msisdn', + '441189998819991197253', + 1555228492026, + 1555228549014 + ); diff --git a/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql b/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql new file mode 100644 index 000000000..bf52d6c5c --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/fixtures/user_alice.sql @@ -0,0 +1,40 @@ +-- +INSERT INTO users + ( + name, + password_hash, + creation_ts, + admin, + upgrade_ts, + is_guest, + appservice_id, + consent_version, + consent_server_notice_sent, + user_type, + deactivated, + shadow_banned, + consent_ts, + approved, + locked, + suspended + ) + VALUES + ( + '@alice:example.com', + '$2b$12$aaa/aaaaaaaaaa.aaaaaaaaaaaaaaa./aaaaaaaaaaaaaaaaaaa/A', + 1530393962, + 0, + NULL, + 0, + NULL, + '1.0', + '1.0', + NULL, + 0, + NULL, + NULL, + NULL, + false, + false + ); + diff --git a/crates/syn2mas/src/synapse_reader/mod.rs b/crates/syn2mas/src/synapse_reader/mod.rs new file mode 100644 index 000000000..7f3b28784 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/mod.rs @@ -0,0 +1,418 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! # Synapse Database Reader +//! +//! This module provides facilities for streaming relevant types of database +//! records from a Synapse database. + +use std::fmt::Display; + +use chrono::{DateTime, Utc}; +use futures_util::{Stream, TryStreamExt}; +use sqlx::{query, Acquire, FromRow, PgConnection, Postgres, Row, Transaction, Type}; +use thiserror::Error; +use thiserror_ext::ContextInto; + +pub mod checks; +pub mod config; + +#[derive(Debug, Error, ContextInto)] +pub enum Error { + #[error("database error whilst {context}")] + Database { + #[source] + source: sqlx::Error, + context: String, + }, +} + +#[derive(Clone, Debug, sqlx::Decode, PartialEq, Eq, PartialOrd, Ord)] +pub struct FullUserId(pub String); + +impl Display for FullUserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Type for FullUserId { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +#[derive(Debug, Error)] +pub enum ExtractLocalpartError { + #[error("user ID does not start with `@` sigil")] + NoAtSigil, + #[error("user ID does not have a `:` separator")] + NoSeparator, + #[error("wrong server name: expected {expected:?}, got {found:?}")] + WrongServerName { expected: String, found: String }, +} + +impl FullUserId { + /// Extract the localpart from the User ID, asserting that the User ID has + /// the correct server name. + /// + /// # Errors + /// + /// A handful of basic validity checks are performed and an error may be + /// returned if the User ID is not valid. + /// However, the User ID grammar is not checked fully. + /// + /// If the wrong server name is asserted, returns an error. + pub fn extract_localpart( + &self, + expected_server_name: &str, + ) -> Result<&str, ExtractLocalpartError> { + let Some(without_sigil) = self.0.strip_prefix('@') else { + return Err(ExtractLocalpartError::NoAtSigil); + }; + + let Some((localpart, server_name)) = without_sigil.split_once(':') else { + return Err(ExtractLocalpartError::NoSeparator); + }; + + if server_name != expected_server_name { + return Err(ExtractLocalpartError::WrongServerName { + expected: expected_server_name.to_owned(), + found: server_name.to_owned(), + }); + }; + + Ok(localpart) + } +} + +/// A Synapse boolean. +/// Synapse stores booleans as 0 or 1, due to compatibility with old SQLite +/// versions that did not have native boolean support. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SynapseBool(bool); + +impl<'r> sqlx::Decode<'r, Postgres> for SynapseBool { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + >::decode(value) + .map(|boolean_int| SynapseBool(boolean_int != 0)) + } +} + +impl sqlx::Type for SynapseBool { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +impl From for bool { + fn from(SynapseBool(value): SynapseBool) -> Self { + value + } +} + +/// A timestamp stored as the number of seconds since the Unix epoch. +/// Note that Synapse stores MOST timestamps as numbers of **milliseconds** +/// since the Unix epoch. But some timestamps are still stored in seconds. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SecondsTimestamp(DateTime); + +impl From for DateTime { + fn from(SecondsTimestamp(value): SecondsTimestamp) -> Self { + value + } +} + +impl<'r> sqlx::Decode<'r, Postgres> for SecondsTimestamp { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + >::decode(value).map(|seconds_since_epoch| { + SecondsTimestamp(DateTime::from_timestamp_nanos( + seconds_since_epoch * 1_000_000_000, + )) + }) + } +} + +impl sqlx::Type for SecondsTimestamp { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +/// A timestamp stored as the number of milliseconds since the Unix epoch. +/// Note that Synapse stores some timestamps in seconds. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MillisecondsTimestamp(DateTime); + +impl From for DateTime { + fn from(MillisecondsTimestamp(value): MillisecondsTimestamp) -> Self { + value + } +} + +impl<'r> sqlx::Decode<'r, Postgres> for MillisecondsTimestamp { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + >::decode(value).map(|milliseconds_since_epoch| { + MillisecondsTimestamp(DateTime::from_timestamp_nanos( + milliseconds_since_epoch * 1_000_000, + )) + }) + } +} + +impl sqlx::Type for MillisecondsTimestamp { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +#[derive(Clone, Debug, FromRow, PartialEq, Eq, PartialOrd, Ord)] +pub struct SynapseUser { + /// Full User ID of the user + pub name: FullUserId, + /// Password hash string for the user. Optional (null if no password is + /// set). + pub password_hash: Option, + /// Whether the user is a Synapse Admin + pub admin: SynapseBool, + /// Whether the user is deactivated + pub deactivated: SynapseBool, + /// When the user was created + pub creation_ts: SecondsTimestamp, + // TODO ... + // TODO is_guest + // TODO do we care about upgrade_ts (users who upgraded from guest accounts to real accounts) +} + +/// Row of the `user_threepids` table in Synapse. +#[derive(Clone, Debug, FromRow, PartialEq, Eq, PartialOrd, Ord)] +pub struct SynapseThreepid { + pub user_id: FullUserId, + pub medium: String, + pub address: String, + pub added_at: MillisecondsTimestamp, +} + +/// Row of the `user_external_ids` table in Synapse. +#[derive(Clone, Debug, FromRow, PartialEq, Eq, PartialOrd, Ord)] +pub struct SynapseExternalId { + pub user_id: FullUserId, + pub auth_provider: String, + pub external_id: String, +} + +/// List of Synapse tables that we should acquire an `EXCLUSIVE` lock on. +/// +/// This is a safety measure against other processes changing the data +/// underneath our feet. It's still not a good idea to run Synapse at the same +/// time as the migration. +// TODO not complete! +const TABLES_TO_LOCK: &[&str] = &["users", "user_threepids", "user_external_ids"]; + +/// Number of migratable rows in various Synapse tables. +/// Used to estimate progress. +#[derive(Clone, Debug)] +pub struct SynapseRowCounts { + pub users: i64, +} + +pub struct SynapseReader<'c> { + txn: Transaction<'c, Postgres>, +} + +impl<'conn> SynapseReader<'conn> { + /// Create a new Synapse reader, which entails creating a transaction and + /// locking Synapse tables. + /// + /// # Errors + /// + /// Errors are returned under the following circumstances: + /// + /// - An underlying database error + /// - If we can't lock the Synapse tables (pointing to the fact that Synapse + /// may still be running) + pub async fn new( + synapse_connection: &'conn mut PgConnection, + dry_run: bool, + ) -> Result { + let mut txn = synapse_connection + .begin() + .await + .into_database("begin transaction")?; + + query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;") + .execute(&mut *txn) + .await + .into_database("set transaction")?; + + let lock_type = if dry_run { + // We expect dry runs to be done alongside Synapse running, so we don't want to + // interfere with Synapse's database access in that case. + "ACCESS SHARE" + } else { + "EXCLUSIVE" + }; + for table in TABLES_TO_LOCK { + query(&format!("LOCK TABLE {table} IN {lock_type} MODE NOWAIT;")) + .execute(&mut *txn) + .await + .into_database_with(|| format!("locking Synapse table `{table}`"))?; + } + + Ok(Self { txn }) + } + + /// Finishes the Synapse reader, committing the transaction. + /// + /// # Errors + /// + /// Errors are returned under the following circumstances: + /// + /// - An underlying database error whilst committing the transaction. + pub async fn finish(self) -> Result<(), Error> { + // TODO enforce that this is called somehow. + self.txn.commit().await.into_database("end transaction")?; + Ok(()) + } + + /// Counts the rows in the Synapse database to get an estimate of how large + /// the migration is going to be. + /// + /// # Errors + /// + /// Errors are returned under the following circumstances: + /// + /// - An underlying database error + pub async fn count_rows(&mut self) -> Result { + let users = sqlx::query( + " + SELECT COUNT(1) FROM users + WHERE appservice_id IS NULL AND is_guest = 0 + ", + ) + .fetch_one(&mut *self.txn) + .await + .into_database("counting Synapse users")? + .try_get::(0) + .into_database("couldn't decode count of Synapse users table")?; + + Ok(SynapseRowCounts { users }) + } + + /// Reads Synapse users, excluding application service users (which do not + /// need to be migrated), from the database. + pub fn read_users(&mut self) -> impl Stream> + '_ { + sqlx::query_as( + " + SELECT + name, password_hash, admin, deactivated, creation_ts + FROM users + WHERE appservice_id IS NULL AND is_guest = 0 + ", + ) + .fetch(&mut *self.txn) + .map_err(|err| err.into_database("reading Synapse users")) + } + + /// Reads threepids (such as e-mail and phone number associations) from + /// Synapse. + pub fn read_threepids(&mut self) -> impl Stream> + '_ { + sqlx::query_as( + " + SELECT + user_id, medium, address, added_at + FROM user_threepids + ", + ) + .fetch(&mut *self.txn) + .map_err(|err| err.into_database("reading Synapse threepids")) + } + + /// Read associations between Synapse users and external identity providers + pub fn read_user_external_ids( + &mut self, + ) -> impl Stream> + '_ { + sqlx::query_as( + " + SELECT + user_id, auth_provider, external_id + FROM user_external_ids + ", + ) + .fetch(&mut *self.txn) + .map_err(|err| err.into_database("reading Synapse user external IDs")) + } +} + +#[cfg(test)] +mod test { + use std::collections::BTreeSet; + + use futures_util::TryStreamExt; + use insta::assert_debug_snapshot; + use sqlx::{migrate::Migrator, PgPool}; + + use crate::{ + synapse_reader::{SynapseExternalId, SynapseThreepid, SynapseUser}, + SynapseReader, + }; + + // TODO test me + static MIGRATOR: Migrator = sqlx::migrate!("./test_synapse_migrations"); + + #[sqlx::test(migrator = "MIGRATOR", fixtures("user_alice"))] + async fn test_read_users(pool: PgPool) { + let mut conn = pool.acquire().await.expect("failed to get connection"); + let mut reader = SynapseReader::new(&mut conn, false) + .await + .expect("failed to make SynapseReader"); + + let users: BTreeSet = reader + .read_users() + .try_collect() + .await + .expect("failed to read Synapse users"); + + assert_debug_snapshot!(users); + } + + #[sqlx::test(migrator = "MIGRATOR", fixtures("user_alice", "threepids_alice"))] + async fn test_read_threepids(pool: PgPool) { + let mut conn = pool.acquire().await.expect("failed to get connection"); + let mut reader = SynapseReader::new(&mut conn, false) + .await + .expect("failed to make SynapseReader"); + + let threepids: BTreeSet = reader + .read_threepids() + .try_collect() + .await + .expect("failed to read Synapse threepids"); + + assert_debug_snapshot!(threepids); + } + + #[sqlx::test(migrator = "MIGRATOR", fixtures("user_alice", "external_ids_alice"))] + async fn test_read_external_ids(pool: PgPool) { + let mut conn = pool.acquire().await.expect("failed to get connection"); + let mut reader = SynapseReader::new(&mut conn, false) + .await + .expect("failed to make SynapseReader"); + + let external_ids: BTreeSet = reader + .read_user_external_ids() + .try_collect() + .await + .expect("failed to read Synapse external user IDs"); + + assert_debug_snapshot!(external_ids); + } +} diff --git a/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_external_ids.snap b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_external_ids.snap new file mode 100644 index 000000000..695007d51 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_external_ids.snap @@ -0,0 +1,13 @@ +--- +source: crates/syn2mas/src/synapse_reader/mod.rs +expression: external_ids +--- +{ + SynapseExternalId { + user_id: FullUserId( + "@alice:example.com", + ), + auth_provider: "oidc-raasu", + external_id: "871.syn30", + }, +} diff --git a/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_threepids.snap b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_threepids.snap new file mode 100644 index 000000000..b89874956 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_threepids.snap @@ -0,0 +1,26 @@ +--- +source: crates/syn2mas/src/synapse_reader/mod.rs +expression: threepids +--- +{ + SynapseThreepid { + user_id: FullUserId( + "@alice:example.com", + ), + medium: "email", + address: "alice@example.com", + added_at: MillisecondsTimestamp( + 2019-04-02T18:09:09.014Z, + ), + }, + SynapseThreepid { + user_id: FullUserId( + "@alice:example.com", + ), + medium: "msisdn", + address: "441189998819991197253", + added_at: MillisecondsTimestamp( + 2019-04-14T07:55:49.014Z, + ), + }, +} diff --git a/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap new file mode 100644 index 000000000..a1ec760f1 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/snapshots/syn2mas__synapse_reader__test__read_users.snap @@ -0,0 +1,23 @@ +--- +source: crates/syn2mas/src/synapse_reader/mod.rs +expression: users +--- +{ + SynapseUser { + name: FullUserId( + "@alice:example.com", + ), + password_hash: Some( + "$2b$12$aaa/aaaaaaaaaa.aaaaaaaaaaaaaaa./aaaaaaaaaaaaaaaaaaa/A", + ), + admin: SynapseBool( + false, + ), + deactivated: SynapseBool( + false, + ), + creation_ts: SecondsTimestamp( + 2018-06-30T21:26:02Z, + ), + }, +} diff --git a/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql b/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql new file mode 100644 index 000000000..5c67dc097 --- /dev/null +++ b/crates/syn2mas/test_synapse_migrations/20250117064958_users.sql @@ -0,0 +1,26 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Brings in the `users` table from Synapse + +CREATE TABLE users ( + name text, + password_hash text, + creation_ts bigint, + admin smallint DEFAULT 0 NOT NULL, + upgrade_ts bigint, + is_guest smallint DEFAULT 0 NOT NULL, + appservice_id text, + consent_version text, + consent_server_notice_sent text, + user_type text, + deactivated smallint DEFAULT 0 NOT NULL, + shadow_banned boolean, + consent_ts bigint, + approved boolean, + locked boolean DEFAULT false NOT NULL, + suspended boolean DEFAULT false NOT NULL +); + diff --git a/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql b/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql new file mode 100644 index 000000000..2ff655979 --- /dev/null +++ b/crates/syn2mas/test_synapse_migrations/20250128141011_threepids.sql @@ -0,0 +1,14 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Brings in the `user_threepids` table from Synapse + +CREATE TABLE user_threepids ( + user_id text NOT NULL, + medium text NOT NULL, + address text NOT NULL, + validated_at bigint NOT NULL, + added_at bigint NOT NULL +); diff --git a/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql b/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql new file mode 100644 index 000000000..09eec8430 --- /dev/null +++ b/crates/syn2mas/test_synapse_migrations/20250128162513_external_ids.sql @@ -0,0 +1,12 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Brings in the `user_external_ids` table from Synapse + +CREATE TABLE user_external_ids ( + auth_provider text NOT NULL, + external_id text NOT NULL, + user_id text NOT NULL +); diff --git a/crates/tasks/src/matrix.rs b/crates/tasks/src/matrix.rs index 226868b04..a4e831ab4 100644 --- a/crates/tasks/src/matrix.rs +++ b/crates/tasks/src/matrix.rs @@ -207,7 +207,9 @@ impl RunnableJob for SyncDevicesJob { .map_err(JobError::retry)?; for (compat_session, _) in page.edges { - devices.insert(compat_session.device.as_str().to_owned()); + if let Some(ref device) = compat_session.device { + devices.insert(device.as_str().to_owned()); + }; cursor = cursor.after(compat_session.id); } diff --git a/docs/config.schema.json b/docs/config.schema.json index fafe759d3..269ae46c1 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1892,6 +1892,7 @@ } }, "Provider": { + "description": "Configuration for one upstream OAuth 2 provider.", "type": "object", "required": [ "client_id", @@ -2036,6 +2037,10 @@ "additionalProperties": { "type": "string" } + }, + "synapse_idp_id": { + "description": "The ID of the provider that was used by Synapse. In order to perform a Synapse-to-MAS migration, this must be specified.\n\n## For providers that used OAuth 2.0 or OpenID Connect in Synapse\n\n### For `oidc_providers`: This should be specified as `oidc-` followed by the ID that was configured as `idp_id` in one of the `oidc_providers` in the Synapse configuration. For example, if Synapse's configuration contained `idp_id: wombat` for this provider, then specify `oidc-wombat` here.\n\n### For `oidc_config` (legacy): Specify `oidc` here.", + "type": "string" } } }, diff --git a/docs/setup/homeserver.md b/docs/setup/homeserver.md index 8870d9de6..614d4b190 100644 --- a/docs/setup/homeserver.md +++ b/docs/setup/homeserver.md @@ -59,7 +59,14 @@ experimental_features: admin_token: "AnotherRandomSecret" # URL to advertise to clients where users can self-manage their account - account_management_url: "http://localhost:8080/account" + # Defaults to the URL advertised by MAS, e.g. `https://{public_mas_domain}/account/` + #account_management_url: "http://localhost:8080/account/" + + # URL which Synapse will use to introspect access tokens + # Defaults to the URL advertised by MAS, e.g. `https://{public_mas_domain}/oauth2/introspect` + # This is useful to override if Synapse has a way to call the auth service's + # introspection endpoint directly, skipping intermediate reverse proxies + #introspection_endpoint: "http://localhost:8080/oauth2/introspect" ``` ## Set up the compatibility layer diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 37896b117..dd7bb0974 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,8 +12,8 @@ "@fontsource/inter": "^5.1.1", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.5", - "@tanstack/react-query": "^5.64.2", - "@tanstack/react-router": "^1.97.14", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-router": "^1.98.4", "@tanstack/router-zod-adapter": "^1.81.5", "@vector-im/compound-design-tokens": "3.0.1", "@vector-im/compound-web": "^7.6.2", @@ -21,11 +21,11 @@ "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "i18next": "^24.2.1", + "i18next": "^24.2.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.4.0", - "swagger-ui-react": "^5.18.2", + "swagger-ui-react": "^5.18.3", "vaul": "^1.1.2", "zod": "^3.24.1" }, @@ -35,32 +35,32 @@ "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", "@codecov/vite-plugin": "^1.8.0", - "@graphql-codegen/cli": "^5.0.3", + "@graphql-codegen/cli": "^5.0.4", "@graphql-codegen/client-preset": "^4.5.1", "@graphql-codegen/typescript-msw": "^3.0.0", - "@storybook/addon-essentials": "^8.5.1", - "@storybook/addon-interactions": "^8.5.1", - "@storybook/react": "^8.5.1", - "@storybook/react-vite": "^8.5.1", + "@storybook/addon-essentials": "^8.5.2", + "@storybook/addon-interactions": "^8.5.2", + "@storybook/react": "^8.5.2", + "@storybook/react-vite": "^8.5.2", "@storybook/test": "^8.5.0", - "@tanstack/react-query-devtools": "^5.64.2", - "@tanstack/router-devtools": "^1.97.14", - "@tanstack/router-vite-plugin": "^1.97.14", + "@tanstack/react-query-devtools": "^5.66.0", + "@tanstack/router-devtools": "^1.98.4", + "@tanstack/router-vite-plugin": "^1.98.6", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.10.9", + "@types/node": "^22.12.0", "@types/react": "19.0.8", "@types/react-dom": "19.0.3", - "@types/swagger-ui-react": "^4.18.3", + "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.0.4", "autoprefixer": "^10.4.20", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.10.0", - "happy-dom": "^16.7.2", + "happy-dom": "^16.8.1", "i18next-parser": "^9.1.0", - "knip": "^5.43.1", + "knip": "^5.43.6", "msw": "^2.7.0", "msw-storybook-addon": "^2.0.4", "postcss": "^8.5.1", @@ -73,7 +73,7 @@ "typescript": "^5.7.3", "vite": "6.0.11", "vite-plugin-compression": "^0.5.1", - "vite-plugin-graphql-codegen": "^3.4.4", + "vite-plugin-graphql-codegen": "^3.4.5", "vite-plugin-manifest-sri": "^0.2.0", "vitest": "^3.0.1" } @@ -217,22 +217,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", + "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", + "@babel/helpers": "^7.26.7", + "@babel/parser": "^7.26.7", "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -478,27 +478,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", + "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.5.tgz", - "integrity": "sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", + "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.5" + "@babel/types": "^7.26.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1018,9 +1018,9 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz", - "integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.7.tgz", + "integrity": "sha512-55gRV8vGrCIYZnaQHQrD92Lo/hYE3Sj5tmbuf0hhHR7sj2CWhEhHU89hbq+UVDXvFG1zUVXJhUkEq1eAfqXtFw==", "license": "MIT", "dependencies": { "core-js-pure": "^3.30.2", @@ -1046,17 +1046,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.5.tgz", - "integrity": "sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", + "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.5", - "@babel/parser": "^7.26.5", + "@babel/parser": "^7.26.7", "@babel/template": "^7.25.9", - "@babel/types": "^7.26.5", + "@babel/types": "^7.26.7", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1065,9 +1065,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.5.tgz", - "integrity": "sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", + "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", "dev": true, "license": "MIT", "dependencies": { @@ -1938,16 +1938,16 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/cli": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.3.tgz", - "integrity": "sha512-ULpF6Sbu2d7vNEOgBtE9avQp2oMgcPY/QBYcCqk0Xru5fz+ISjcovQX29V7CS7y5wWBRzNLoXwJQGeEyWbl05g==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.4.tgz", + "integrity": "sha512-vPO1mCtrttFVy8mPR+jMAvsYTv8E/7payIPaneeGE15mQjyvQXXsHoAg06Qpf6tykOdCwKVLWre0Mf6g0KBwUg==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^4.4.0", + "@graphql-codegen/client-preset": "^4.6.0", "@graphql-codegen/core": "^4.0.2", "@graphql-codegen/plugin-helpers": "^5.0.3", "@graphql-tools/apollo-engine-loader": "^8.0.0", @@ -1960,7 +1960,7 @@ "@graphql-tools/prisma-loader": "^8.0.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", - "@whatwg-node/fetch": "^0.9.20", + "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", "cosmiconfig": "^8.1.3", "debounce": "^1.2.0", @@ -2000,21 +2000,21 @@ } }, "node_modules/@graphql-codegen/client-preset": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.5.1.tgz", - "integrity": "sha512-UE2/Kz2eaxv35HIXFwlm2QwoUH77am6+qp54aeEWYq+T+WPwmIc6+YzqtGiT/VcaXgoOUSgidREGm9R6jKcf9g==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.6.0.tgz", + "integrity": "sha512-+zSdT2ru3BOX6e1iuBk2VMe04HumJQQZDCXO4N2LXzv9c15ohFmjY8HdTtFjoi9IKsAH4fT32BzszY6pqVSvHw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^5.0.3", - "@graphql-codegen/gql-tag-operations": "4.0.12", + "@graphql-codegen/gql-tag-operations": "4.0.13", "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/typed-document-node": "^5.0.12", - "@graphql-codegen/typescript": "^4.1.2", - "@graphql-codegen/typescript-operations": "^4.4.0", - "@graphql-codegen/visitor-plugin-common": "^5.6.0", + "@graphql-codegen/typed-document-node": "^5.0.13", + "@graphql-codegen/typescript": "^4.1.3", + "@graphql-codegen/typescript-operations": "^4.4.1", + "@graphql-codegen/visitor-plugin-common": "^5.6.1", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -2058,14 +2058,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.12.tgz", - "integrity": "sha512-v279i49FJ5dMmQXIGUgm6FtnnkxtJjVJWDNYh9JK4ppvOixdHp+PmEzW227DkLN6avhVxNnYdp/1gdRBwdWypw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.13.tgz", + "integrity": "sha512-oZYa57ywkCAPZdNmiUknoHnHbPx+5HQNfHDVwCBKRKrVmJS0dMVZuHV0Vr/4GQUNQIFP0Jn6EEFjVlqfPwbEMQ==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.6.0", + "@graphql-codegen/visitor-plugin-common": "5.6.1", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -2135,14 +2135,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.0.12.tgz", - "integrity": "sha512-Wsbc1AqC+MFp3maWPzrmmyHLuWCPB63qBBFLTKtO6KSsnn0KnLocBp475wkfBZnFISFvzwpJ0e6LV71gKfTofQ==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.0.13.tgz", + "integrity": "sha512-/r23W1WF9PKymIET3SdCDfyuZ6tHeflvbZF3mL3cMp4849M1fe1J2eWefeqn2MMbKATstNqRVxtrq6peJ3A/Ew==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.6.0", + "@graphql-codegen/visitor-plugin-common": "5.6.1", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" @@ -2162,15 +2162,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.2.tgz", - "integrity": "sha512-GhPgfxgWEkBrvKR2y77OThus3K8B6U3ESo68l7+sHH1XiL2WapK5DdClViblJWKQerJRjfJu8tcaxQ8Wpk6Ogw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-/7qNPj+owhxBZB3Kv0FuUILZq9A6Gl5P5wiIZGAmw500n6Vc8ceOFLRXeVkyvDccxTGWS/vJv+sUnl94T2Pu+A==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^5.1.0", "@graphql-codegen/schema-ast": "^4.0.2", - "@graphql-codegen/visitor-plugin-common": "5.6.0", + "@graphql-codegen/visitor-plugin-common": "5.6.1", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2546,15 +2546,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.4.0.tgz", - "integrity": "sha512-oVlos2ySx8xIbbe8r5ZI6mOpI+OTeP14RmS2MchBJ6DL+S9G16O6+9V3Y8V22fTnmBTZkTfAAaBv4HYhhDGWVA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.4.1.tgz", + "integrity": "sha512-iqAdEe4wfxGPT9s/VD+EhehBzaTxvWdisbsqiM6dMfk+8FfjrOj8SDBsHzKwmkRcrpMK6h9gLr3XcuBPu0JoFg==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/typescript": "^4.1.2", - "@graphql-codegen/visitor-plugin-common": "5.6.0", + "@graphql-codegen/typescript": "^4.1.3", + "@graphql-codegen/visitor-plugin-common": "5.6.1", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2580,9 +2580,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.6.0.tgz", - "integrity": "sha512-PowcVPJbUqMC9xTJ/ZRX1p/fsdMZREc+69CM1YY+AlFng2lL0zsdBskFJSRoviQk2Ch9IPhKGyHxlJCy9X22tg==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.6.1.tgz", + "integrity": "sha512-q+DkGWWS7pvSc1c4Hw1xD0RI+EplTe2PCyTCT0WuaswnodBytteKTqFOVVGadISLX0xhO25aANTFB4+TLwTBSA==", "dev": true, "license": "MIT", "dependencies": { @@ -2647,35 +2647,6 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/fetch": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", - "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.7.7", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/node-fetch": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.7.tgz", - "integrity": "sha512-BDbIMOenThOTFDBLh1WscgBNAxfDAdAdd9sMG8Ff83hYxApJVbqEct38bUAj+zn8bTsfBx/lyfnVOTyq5xUlvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/disposablestack": "^0.0.5", - "busboy": "^1.6.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@graphql-tools/batch-execute": { "version": "9.0.11", "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.11.tgz", @@ -2839,35 +2810,6 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor-http/node_modules/@whatwg-node/fetch": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", - "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.7.7", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@graphql-tools/executor-http/node_modules/@whatwg-node/node-fetch": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.7.tgz", - "integrity": "sha512-BDbIMOenThOTFDBLh1WscgBNAxfDAdAdd9sMG8Ff83hYxApJVbqEct38bUAj+zn8bTsfBx/lyfnVOTyq5xUlvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/disposablestack": "^0.0.5", - "busboy": "^1.6.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@graphql-tools/executor-legacy-ws": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.10.tgz", @@ -2931,35 +2873,6 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/fetch": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", - "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.7.7", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/node-fetch": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.7.tgz", - "integrity": "sha512-BDbIMOenThOTFDBLh1WscgBNAxfDAdAdd9sMG8Ff83hYxApJVbqEct38bUAj+zn8bTsfBx/lyfnVOTyq5xUlvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/disposablestack": "^0.0.5", - "busboy": "^1.6.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@graphql-tools/graphql-file-loader": { "version": "8.0.12", "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.0.12.tgz", @@ -3122,35 +3035,6 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/prisma-loader/node_modules/@whatwg-node/fetch": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", - "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.7.7", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@graphql-tools/prisma-loader/node_modules/@whatwg-node/node-fetch": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.7.tgz", - "integrity": "sha512-BDbIMOenThOTFDBLh1WscgBNAxfDAdAdd9sMG8Ff83hYxApJVbqEct38bUAj+zn8bTsfBx/lyfnVOTyq5xUlvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/disposablestack": "^0.0.5", - "busboy": "^1.6.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@graphql-tools/relay-operation-optimizer": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.12.tgz", @@ -3215,35 +3099,6 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/fetch": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", - "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.7.7", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/node-fetch": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.7.tgz", - "integrity": "sha512-BDbIMOenThOTFDBLh1WscgBNAxfDAdAdd9sMG8Ff83hYxApJVbqEct38bUAj+zn8bTsfBx/lyfnVOTyq5xUlvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/disposablestack": "^0.0.5", - "busboy": "^1.6.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@graphql-tools/utils": { "version": "10.7.2", "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.7.2.tgz", @@ -3585,13 +3440,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kamilkisiela/fast-url-parser": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@kamilkisiela/fast-url-parser/-/fast-url-parser-1.1.4.tgz", - "integrity": "sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew==", - "dev": true, - "license": "MIT" - }, "node_modules/@mdx-js/react": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", @@ -4949,9 +4797,9 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.5.1.tgz", - "integrity": "sha512-oBBSpOJ6/rCdbdU1JxGCLernaCxALLWDIeZk6tLoQbtbsx/czD1sodqjcujjKwbQwNyZTf8xR8zsCSzG06dWDw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.5.2.tgz", + "integrity": "sha512-g0gLesVSFgstUq5QphsLeC1vEdwNHgqo2TE0m+STM47832xbxBwmK6uvBeqi416xZvnt1TTKaaBr4uCRRQ64Ww==", "dev": true, "license": "MIT", "dependencies": { @@ -4966,13 +4814,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.5.1.tgz", - "integrity": "sha512-4NFRFblPbRP3D4o4sSbJ1x9SMncP4+SHdSqKIovTjb+zOhqYPFYWMTinzEndUnBSDGREldHUvHjROuxrD/0qzA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.5.2.tgz", + "integrity": "sha512-l9WkI4QHfINeFQkW9K0joaM7WweKktwIIyUPEvyoupHT4n9ccJHAlWjH4SBmzwI1j1Zt0G3t+bq8mVk/YK6Fsg==", "dev": true, "license": "MIT", "dependencies": { @@ -4985,13 +4833,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-controls": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.5.1.tgz", - "integrity": "sha512-RA/SPXW1chfsWaV8Lv/aXJNZJ8hasDEXQ1C5xRCt+T8DFvPqRZGgUfIpsiZ80AKp5RzufT9KL+39piPMljhKXA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.5.2.tgz", + "integrity": "sha512-wkzw2vRff4zkzdvC/GOlB2PlV0i973u8igSLeg34TWNEAa4bipwVHnFfIojRuP9eN1bZL/0tjuU5pKnbTqH7aQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5004,20 +4852,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-docs": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.5.1.tgz", - "integrity": "sha512-XhELkuNFOa8q2rF/AXTwnKZth7lCFqkfR5VuEAQ+g9hv2p6I/VGlTddylzjdaZKhiy4p8O9DrzGdLFj+oxOpMw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.5.2.tgz", + "integrity": "sha512-pRLJ/Qb/3XHpjS7ZAMaOZYtqxOuI8wPxVKYQ6n5rfMSj2jFwt5tdDsEJdhj2t5lsY8HrzEZi8ExuW5I5RoUoIQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.5.1", - "@storybook/csf-plugin": "8.5.1", - "@storybook/react-dom-shim": "8.5.1", + "@storybook/blocks": "8.5.2", + "@storybook/csf-plugin": "8.5.2", + "@storybook/react-dom-shim": "8.5.2", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", "ts-dedent": "^2.0.0" @@ -5027,7 +4875,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-docs/node_modules/react": { @@ -5068,21 +4916,21 @@ } }, "node_modules/@storybook/addon-essentials": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.5.1.tgz", - "integrity": "sha512-jPGrZ7j+RWistrsgpvjUBvLpWRuOeDNdV014ggHBxDMNX9GWb1GSubWW2Tlo7BfOuUvjICVAjI4KMp/IC/jwZg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.5.2.tgz", + "integrity": "sha512-MfojJKxDg0bnjOE0MfLSaPweAud1Esjaf1D9M8EYnpeFnKGZApcGJNRpHCDiHrS5BMr8hHa58RDVc7ObFTI4Dw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/addon-actions": "8.5.1", - "@storybook/addon-backgrounds": "8.5.1", - "@storybook/addon-controls": "8.5.1", - "@storybook/addon-docs": "8.5.1", - "@storybook/addon-highlight": "8.5.1", - "@storybook/addon-measure": "8.5.1", - "@storybook/addon-outline": "8.5.1", - "@storybook/addon-toolbars": "8.5.1", - "@storybook/addon-viewport": "8.5.1", + "@storybook/addon-actions": "8.5.2", + "@storybook/addon-backgrounds": "8.5.2", + "@storybook/addon-controls": "8.5.2", + "@storybook/addon-docs": "8.5.2", + "@storybook/addon-highlight": "8.5.2", + "@storybook/addon-measure": "8.5.2", + "@storybook/addon-outline": "8.5.2", + "@storybook/addon-toolbars": "8.5.2", + "@storybook/addon-viewport": "8.5.2", "ts-dedent": "^2.0.0" }, "funding": { @@ -5090,13 +4938,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-highlight": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.5.1.tgz", - "integrity": "sha512-nhwx39DuWy2OFP+AQg8EzYP3giM+rQ0OIdAXgAjDVdKk2sGj43gwNYS9wQzXeczEUiSEjQk0JJwBqjF+GtSrag==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.5.2.tgz", + "integrity": "sha512-QjJfY+8e1bi6FeGfVlgxzv/I8DUyC83lZq8zfTY7nDUCVdmKi8VzmW0KgDo5PaEOFKs8x6LKJa+s5O0gFQaJMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5107,19 +4955,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-interactions": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.5.1.tgz", - "integrity": "sha512-tXCKBIWjwhVuSRRoEiPx+u0D4oqMkctTzysfoCw2sqftIT8t2yHyviX29s87z2NH+DNqzBGGDG1UUaLe5qq3Fw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.5.2.tgz", + "integrity": "sha512-Gn9Egk2OS0BkkHd671Y0pIqBr4noAOLUfnpxhHE8r0Tt7FmJFeVSN+dqK7hQeUmKL5jdSY25FTYROg65JmtGOA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.5.1", - "@storybook/test": "8.5.1", + "@storybook/instrumenter": "8.5.2", + "@storybook/test": "8.5.2", "polished": "^4.2.2", "ts-dedent": "^2.2.0" }, @@ -5128,13 +4976,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-measure": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.5.1.tgz", - "integrity": "sha512-Goc/IRh0aYT7zfDP9fgwL+DFX52DylanoBf0uGf59IQ7sEJHbwWm0OpiSEDo+NbtytbG83UOQamT7aQxhQo7Zw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.5.2.tgz", + "integrity": "sha512-g7Kvrx8dqzeYWetpWYVVu4HaRzLAZVlOAlZYNfCH/aJHcFKp/p5zhPXnZh8aorxeCLHW1QSKcliaA4BNPEvTeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5146,13 +4994,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-outline": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.5.1.tgz", - "integrity": "sha512-LM3wG5bUgAAEgDS4MD1dw2VStduSYTMc/rNgaTExVVr7pPeuAgkfyIUriP3P0i7x5jweSb2aGzaTuy3PUHAWfg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.5.2.tgz", + "integrity": "sha512-laMVLT1xluSqMa2mMzmS1kdKcjX0HI9Fw+7pM3r4drtGWtxpyBT32YFqKfWFIBhcd364ti2tDUz9FlygGQ1rKw==", "dev": true, "license": "MIT", "dependencies": { @@ -5164,13 +5012,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.5.1.tgz", - "integrity": "sha512-01Odzujfq/g9u1ZTmH/X3I9cCnsNzG/wuyhzFr/T99jerx8QG/U45iYYph2Ytw6A5AtYyCnPYmsTsI+phjUvuA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.5.2.tgz", + "integrity": "sha512-gHQtVCiq7HRqdYQLOmX8nhtV1Lqz4tOCj4BVodwwf8fUcHyNor+2FvGlQjngV2pIeCtxiM/qmG63UpTBp57ZMA==", "dev": true, "license": "MIT", "funding": { @@ -5178,13 +5026,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/addon-viewport": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.5.1.tgz", - "integrity": "sha512-kKCXZT3keUEQulv2tOzRSl/GdFA2JeFjHmks/n7qQLY0zDqdx/C7K9jUECcrOJiLclZwTJvHA3YXrglVJoa6Hw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.5.2.tgz", + "integrity": "sha512-W+7nrMQmxHcUNGsXjmb/fak1mD0a5vf4y1hBhSM7/131t8KBsvEu4ral8LTUhc4ZzuU1eIUM0Qth7SjqHqm5bA==", "dev": true, "license": "MIT", "dependencies": { @@ -5195,13 +5043,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/blocks": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.5.1.tgz", - "integrity": "sha512-xUjnOa9udmHhlBTZ+bmMHeU1M9a5OnvnX8urQ0TrNpSyHH7HoPd3xZC4fzz73nSJNMVHIYMZYsz2pj/WfeA/hg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.5.2.tgz", + "integrity": "sha512-C6Bz/YTG5ZuyAzglqgqozYUWaS39j1PnkVuMNots6S3Fp8ZJ6iZOlQ+rpumiuvnbfD5rkEZG+614RWNyNlFy7g==", "dev": true, "license": "MIT", "dependencies": { @@ -5216,7 +5064,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.5.1" + "storybook": "^8.5.2" }, "peerDependenciesMeta": { "react": { @@ -5228,13 +5076,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.5.1.tgz", - "integrity": "sha512-m7nzMmXL8ySRDp3AWsd18xB/mRVFdGnCbXeC2HREQVsu1WFkvcHtksvF4x1BOeeL73eokD2/GzgpCjAS0xVvbw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.5.2.tgz", + "integrity": "sha512-5YWCHmWtZ6oBEqpcGvAmBXVfeX+zssIGWE/UUUnjkmlXO7tHvFccikOLV7/p5VCHH21AbXN8F6mnptEsMPbqqg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "8.5.1", + "@storybook/csf-plugin": "8.5.2", "browser-assert": "^1.2.1", "ts-dedent": "^2.0.0" }, @@ -5243,14 +5091,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1", + "storybook": "^8.5.2", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" } }, "node_modules/@storybook/components": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.5.1.tgz", - "integrity": "sha512-dgZfIIRdI7yA9bYb1rhWzbvU4AnbndAeNhLouxHJkUR5r2Ycp9mJba5UNynN1slgDOxB+VMnq1fWKyfWQrBqnw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.5.2.tgz", + "integrity": "sha512-o5vNN30sGLTJBeGk5SKyekR4RfTpBTGs2LDjXGAmpl2MRhzd62ix8g+KIXSR0rQ55TCvKUl5VR2i99ttlRcEKw==", "dev": true, "license": "MIT", "funding": { @@ -5262,9 +5110,9 @@ } }, "node_modules/@storybook/core": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.5.1.tgz", - "integrity": "sha512-4zxjclENpZYuNY1fZJE4a7hd8Ho/SiOSN2B57fsIi1qCpKax3JU3J59ZcAWT0iidy5qgM2qMcWbrl0Bl/tWamA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.5.2.tgz", + "integrity": "sha512-rCOpXZo2XbdKVnZiv8oC9FId/gLkStpKGGL7hhdg/RyjcyUyTfhsvaf7LXKZH2A0n/UpwFxhF3idRfhgc1XiSg==", "dev": true, "license": "MIT", "dependencies": { @@ -5304,9 +5152,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.5.1.tgz", - "integrity": "sha512-8GFrQgJ+/hzWAj9o4XK8m7UFPLxf0w3RwX0ZMPeb6zDhq/1BUE97AjKFb4Oexkh4I67Pycv4gRUOY9+tXF/1DA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.5.2.tgz", + "integrity": "sha512-EEQ3Vc9qIUbLH8tunzN/GSoyP3zPpNPKegZooYQbgVqA582Pel4Jnpn4uxGaOWtFCLhXMETV05X/7chGZtEujA==", "dev": true, "license": "MIT", "dependencies": { @@ -5317,7 +5165,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/global": { @@ -5328,9 +5176,9 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.3.1.tgz", - "integrity": "sha512-tgiD2v9v/4sjGOliemoP/8bUe4+ZFpehcqdCVQcPiGZfV0kSBv34Ge+MafeKqM7SLwvGesrbOEOakaogSqGxiQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.3.2.tgz", + "integrity": "sha512-t3xcbCKkPvqyef8urBM0j/nP6sKtnlRkVgC+8JTbTAZQjaTmOjes3byEgzs89p4B/K6cJsg9wLW2k3SknLtYJw==", "dev": true, "license": "MIT", "engines": { @@ -5342,9 +5190,9 @@ } }, "node_modules/@storybook/instrumenter": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.5.1.tgz", - "integrity": "sha512-wMAhsIzwOh/xXKANAP3IbtXxRWFAZtpRisB0sy8WVTPS3a1L1cA6X+U80Ex/omek6L0FZwKZSKmmfkDeZkYnCQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.5.2.tgz", + "integrity": "sha512-BbaUw9GXVzRg3Km95t2mRu4W6C1n1erjzll5maBaVe2+lV9MbCvBcdYwGUgjFNlQ/ETgq6vLfLOEtziycq/B6g==", "dev": true, "license": "MIT", "dependencies": { @@ -5356,13 +5204,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/manager-api": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.5.1.tgz", - "integrity": "sha512-Oj9kPYbp/82LRQ+rsc0ZH0fkzeiT2U1kvubmNiRjtopQHCP3UTVnvWIXC9zSRFKmS+NaAdd0JYsIBvE8fjnoqQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.5.2.tgz", + "integrity": "sha512-Cn+oINA6BOO2GmGHinGsOWnEpoBnurlZ9ekMq7H/c1SYMvQWNg5RlELyrhsnyhNd83fqFZy9Asb0RXI8oqz7DQ==", "dev": true, "license": "MIT", "funding": { @@ -5374,9 +5222,9 @@ } }, "node_modules/@storybook/preview-api": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.5.1.tgz", - "integrity": "sha512-fLR7nvAbjHVLazDA6CLy9O/bpBzKDKqxyBp6SybTBPYa76IzsX8ITSMMt1YcP6rOGhVgcKNA9iBNxRddjLIV0Q==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.5.2.tgz", + "integrity": "sha512-AOOaBjwnkFU40Fi68fvAnK0gMWPz6o/AmH44yDGsHgbI07UgqxLBKCTpjCGPlyQd5ezEjmGwwFTmcmq5dG8DKA==", "dev": true, "license": "MIT", "funding": { @@ -5388,18 +5236,18 @@ } }, "node_modules/@storybook/react": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.5.1.tgz", - "integrity": "sha512-wKhR9SZUbpYUxRDAYUHH4fZHVxiNG43PxT1uvLfX/i7TPMw+wW+G3Q2yrgms1oHmqqRCvlnGHwT5/t9FFxN31w==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.5.2.tgz", + "integrity": "sha512-hWzw9ZllfzsaBJdAoEqPQ2GdVNV4c7PkvIWM6z67epaOHqsdsKScbTMe+YAvFMPtLtOO8KblIrtU5PeD4KyMgw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/components": "8.5.1", + "@storybook/components": "8.5.2", "@storybook/global": "^5.0.0", - "@storybook/manager-api": "8.5.1", - "@storybook/preview-api": "8.5.1", - "@storybook/react-dom-shim": "8.5.1", - "@storybook/theming": "8.5.1" + "@storybook/manager-api": "8.5.2", + "@storybook/preview-api": "8.5.2", + "@storybook/react-dom-shim": "8.5.2", + "@storybook/theming": "8.5.2" }, "engines": { "node": ">=18.0.0" @@ -5409,10 +5257,10 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@storybook/test": "8.5.1", + "@storybook/test": "8.5.2", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.5.1", + "storybook": "^8.5.2", "typescript": ">= 4.2.x" }, "peerDependenciesMeta": { @@ -5425,9 +5273,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.5.1.tgz", - "integrity": "sha512-peDiT6A1zyODKd7tVQIiFNU42Iolca67h3kkOQPb7nm/Czf2yIa/BHw+yiNDZx82eCIEvBy1Xf7lnjH8PD61xA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.5.2.tgz", + "integrity": "sha512-lt7XoaeWI8iPlWnWzIm/Wam9TpRFhlqP0KZJoKwDyHiCByqkeMrw5MJREyWq626nf34bOW8D6vkuyTzCHGTxKg==", "dev": true, "license": "MIT", "funding": { @@ -5437,20 +5285,20 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/react-vite": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.5.1.tgz", - "integrity": "sha512-ccsPJXjR7WMS/t7R5nJpPtqRzJxjsllqVMNGk9xxoLasWDf3vOLohgyCgt63ws8iOMh26lqZsFyPyWFcpKW/hQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.5.2.tgz", + "integrity": "sha512-MHsBuW23Qx6Kc55vwZ3zg6a5rkzReIcEPm38gm3vuf9vuvUsnXgvYRcu8xg3z8GakpsQNSZZJ/1sH48l0XvsSQ==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.4.2", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "8.5.1", - "@storybook/react": "8.5.1", + "@storybook/builder-vite": "8.5.2", + "@storybook/react": "8.5.2", "find-up": "^5.0.0", "magic-string": "^0.30.0", "react-docgen": "^7.0.0", @@ -5465,10 +5313,10 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@storybook/test": "8.5.1", + "@storybook/test": "8.5.2", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.5.1", + "storybook": "^8.5.2", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { @@ -5478,15 +5326,15 @@ } }, "node_modules/@storybook/test": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.5.1.tgz", - "integrity": "sha512-V0sEXqL5kS0YKugCqWgmCpNODdlCCiVlPqm3i+E2+G97DR980BwXf8J6VPscQDRS9ZG39BrM83Aau6Anxrt1Tg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.5.2.tgz", + "integrity": "sha512-F5WfD75m25ZRS19cSxCzHWJ/rH8jWwIjhBlhU+UW+5xjnTS1cJuC1yPT/5Jw0/0Aj9zG1atyfBUYnNHYtsBDYQ==", "dev": true, "license": "MIT", "dependencies": { "@storybook/csf": "0.1.12", "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.5.1", + "@storybook/instrumenter": "8.5.2", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", @@ -5498,7 +5346,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.5.1" + "storybook": "^8.5.2" } }, "node_modules/@storybook/test/node_modules/@testing-library/jest-dom": { @@ -5558,9 +5406,9 @@ "license": "MIT" }, "node_modules/@storybook/theming": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.5.1.tgz", - "integrity": "sha512-sg61vY1gM8w42CIi28vo//6E1gHgHLNBNaRhkfvLFpu9PuhAcVWLwBDZq0BoKmDMxRxbSPV2gvIKeXdOtbSCJw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.5.2.tgz", + "integrity": "sha512-vro8vJx16rIE0UehawEZbxFFA4/VGYS20PMKP6Y6Fpsce0t2/cF/U9qg3jOzVb/XDwfx+ne3/V+8rjfWx8wwJw==", "dev": true, "license": "MIT", "funding": { @@ -5572,13 +5420,13 @@ } }, "node_modules/@swagger-api/apidom-ast": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.6.tgz", - "integrity": "sha512-AAxEN/xTcH/ORpn/zEEuPPgtqX6/Q9EZC8RX2R7AlRdUeGZieE9OZ91mXYrg48FcHWi/xwWYqkPPHjyXTQkfww==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.11.tgz", + "integrity": "sha512-QvKVHSYdYNISzq5ONqdfl0QfbVdNosVsgDMrXrB6AP0HoF0rR84Hvj16aPKfYYKU4cfB+orz4u4dWi0xdiDqtA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-error": "^1.0.0-beta.6", + "@swagger-api/apidom-error": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5586,14 +5434,14 @@ } }, "node_modules/@swagger-api/apidom-core": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.0.0-beta.6.tgz", - "integrity": "sha512-gmHpE5+wJgUmpkb0C3ZIM6VsMXj0heujwQeXqEcFRkp1d0u4crCNmQ5iPTewzvILcnMbxac0AUFFKuJbBpqzPg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.0.0-beta.11.tgz", + "integrity": "sha512-WnowOdBwezElnbyhw5DXiQc6fFw9ld3g3aMqCmal4tleN3QB4bnlWWXVXKDDDhvhfTx3HaQB/UlPWUMQwInb7g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", + "@swagger-api/apidom-ast": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "minim": "~0.23.8", "ramda": "~0.30.0", @@ -5603,39 +5451,39 @@ } }, "node_modules/@swagger-api/apidom-error": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.0.0-beta.6.tgz", - "integrity": "sha512-bLttwjXj0u9pHIzc71L5rZWvhtcPFmGdvPDpXMoK4XOjmfpw9hqQKg1DGWKQHxNiMP/zlWAWO1RxjFQNYcO70g==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.0.0-beta.11.tgz", + "integrity": "sha512-J/GrqtjrNZUWhgdKS90A3+ctBhkR5DBRGZUHxoBjNPR61iGWd/GLJjAcChwmNM7gWKi7PGwOi2q1AqMecqubkQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7" } }, "node_modules/@swagger-api/apidom-json-pointer": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-beta.6.tgz", - "integrity": "sha512-9XdWnouDGnn8UCr48TgtB16e4s37L7ibWFFgn4ercSkUMsJKMzHULabZ005IKVfP20UbhdIa5/r2W/i8iRk8Vg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-beta.11.tgz", + "integrity": "sha512-dR9Kj4W26YpxiacSYiGye63vPQlumo7Gdqlb7wtfIEKBI3H6QIXmN6ehGbgQ65AfM/eYo/LlUMfFygQNzWLP3A==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-ns-api-design-systems": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.0.0-beta.6.tgz", - "integrity": "sha512-Qycf1LbBP5KxtxCeXHIAKazekKnz8kOHfnn2JT/FkWojM4reTECHBMi40DwQOQbj1CsWSasoTbnKjG64BxoJRg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.0.0-beta.11.tgz", + "integrity": "sha512-otqTNZf+yG7ZTDo5wzin6jcTqO9KwoAni8iwS8s359Et8sp5DwjTSR86vSg1rZsUIqw/WWDGrPRkTHU73tOX8w==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5643,30 +5491,62 @@ } }, "node_modules/@swagger-api/apidom-ns-asyncapi-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.0.0-beta.6.tgz", - "integrity": "sha512-bQ0eNdDYrrkr4Y4iyUcgXiYBFzj+wwJiBGKI8OBJ9hTVEDbDCb/8ZzlZw3wMQNGFMw6/NC2F6MEbocApDx9vnQ==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.0.0-beta.11.tgz", + "integrity": "sha512-xFu/xZlmhbCo2WAyfTlaiRquH7AEnArAwjNynd3CWnerNJ0NMuO1OBsUh8JI3WJQPRKdtYyH0zP5bnVk9aHSYA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-beta.6.tgz", - "integrity": "sha512-Cn4+CH8ZqniejbmbD7nfUzw/N+S9lwGztOB5ZSoS23r1/mFzcya/bTOSuUW6BJ4Pa1L+AvUWhqmRlzG66Ta0gA==", + "node_modules/@swagger-api/apidom-ns-json-schema-2019-09": { + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.0.0-beta.11.tgz", + "integrity": "sha512-aNU+5qylnl9//pywt/aJHece6sJaSHE5PoCoBQYQ+La6Dt7xhMU5zRQv2inCpxcPP4H3F2yapXqsB+VU/If3yA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^1.0.0-beta.6", - "@swagger-api/apidom-core": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.0.0-beta.11", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-2020-12": { + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.0.0-beta.11.tgz", + "integrity": "sha512-3WzQ9ykmTYR14cvjgZCpMODvaTcSLcScayAjWPXfH6cc8qtAf786654C52qtpMaQNbpWrdMz93LUvBFrBrGJhA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-2019-09": "^1.0.0-beta.11", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-beta.11.tgz", + "integrity": "sha512-5iAzcjFfai5mVKDeytG2M9R8kG9xSMhfzgNFvFX5X7NwPrHZeNJwS02BXwHPksBiE6QdfS6tDeF9iMToDCR+Yw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^1.0.0-beta.11", + "@swagger-api/apidom-core": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5674,16 +5554,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-beta.6.tgz", - "integrity": "sha512-zwD2cKjbXBynMNFAyXHLsNz16Wbd4SOSehAZ1WJcWTJflC0GVk0kkFmzGFz92WI0YQihnrYwrAhpmZohUlHUWg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-beta.11.tgz", + "integrity": "sha512-Cmaw1JWP1UWrqFKgbRp+BTCsB0yuhLVBOL1Glf439+jk95cz7v14B7+P+/wN4tqn4bcmzXyhD6KJ9DYzHiHd2A==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5691,16 +5570,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-beta.6.tgz", - "integrity": "sha512-T1LMWiHitPJt9pM4G4FTPaGJntW8x6v/Y6236dEt8gO5aw5T3528PtaqEGfmI4uIvJO4dBwrobEte9GUXWVxig==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-beta.11.tgz", + "integrity": "sha512-I0Ls6It89Dvkm5lOMGF79Sbk3QcPP7Ijo7Ay5QEiLTD+u7jcfpJIp/scOCzdQLCpTWxzzLe4FY88NuauzTfuJw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-json-schema-draft-6": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-draft-6": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5708,16 +5586,16 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.0.0-beta.6.tgz", - "integrity": "sha512-SY+h67maS88egPr9o7E8yep2xdw4N/vRYO1vCRcX4Y6UfFpiAn3jSKxQkOP983DJGXwDLVirVML/ezb9VXbnDg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.0.0-beta.11.tgz", + "integrity": "sha512-aVi48Dv3pk/QzxPXH/jmOReiGU01gkgkEFUbBiNGxuHD/bzS4SbLLdkw6ai8f8eQPpXI6xKP5F6LhFHWTE0IJg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5725,15 +5603,15 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-0": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.0.0-beta.6.tgz", - "integrity": "sha512-fqsF35X8O2yaENr74wbZtPqSgiuuomu9mT9KKj9P7z6in6SjBSTMMmGkbsjximdr+hVCrNm8ActDF1HRq3av7Q==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.0.0-beta.11.tgz", + "integrity": "sha512-amY1KI9Vx0Vye1OQ3K1mPJmL4ONWSZwWcH6COWer8aExNq66W7oWTXOfL3q6NAgRC6NGobbrXgFGXd196N7fNw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5741,16 +5619,17 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-1": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-beta.6.tgz", - "integrity": "sha512-GOtloezNXZExvhmSp5OT2NO7XLMwUY12stXUWl0bWR3O/6I6U522JFgoO9SHKxuSed5ateJpE7eR39HCJ/pyOQ==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-beta.11.tgz", + "integrity": "sha512-Y9rChJPfBZYJzo33XZivRSEgnZf07GLRLV5eX4kLGeNNI6UtzxJ1mQR4WpLn9CkrN+JrGguNpH1xMdDs/b/J7g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^1.0.0-beta.6", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-json-pointer": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-beta.6", + "@swagger-api/apidom-ast": "^1.0.0-beta.11", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-json-pointer": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5758,15 +5637,15 @@ } }, "node_modules/@swagger-api/apidom-ns-workflows-1": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-workflows-1/-/apidom-ns-workflows-1-1.0.0-beta.6.tgz", - "integrity": "sha512-5ViXxpioBNfkJJyGmgbp76OyvY3IRsfNwN9tXTl39vgpyPnQVtBPwhKwuViiqDr+GmyZgMCotB3QlYPNcxqEoA==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-workflows-1/-/apidom-ns-workflows-1-1.0.0-beta.11.tgz", + "integrity": "sha512-tsQicBkvHfpEWaBap4Ip0F2BbyI+qbLAdmP1e9LwYeqchrCjE4FJuDGCpHzmdNBIgkGw0aH3OyPUeZsUKM3r2A==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5774,80 +5653,80 @@ } }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.0.0-beta.6.tgz", - "integrity": "sha512-HLcCDO7QdBjPFQ/Mf4XmG0qcmwW+AnDZyPYzMOAyK1hU3xwQjAIn5zOlgp0feTe3vNUMzNY1NDHvCeDXSbN5sQ==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.0.0-beta.11.tgz", + "integrity": "sha512-kya/aWYL/tkJ7P12FOztRzGo8w8zajkN33KPbi0cVwiyB82yDlor2A4CodSXsy/vSEWVrqMVvz5lBVsUe2YgFQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-api-design-systems": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-api-design-systems": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.0.0-beta.6.tgz", - "integrity": "sha512-jL2fZv1a+3S6SiIVYc3kC0NAAk8bszNGcVTsBV8ehHgZxc0I+EANEJwgZE/YOcL3TlNEFscfjUcGhjyWkEQksQ==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.0.0-beta.11.tgz", + "integrity": "sha512-tGzU7/FH/PqiUxHvbADtrSH9Lp7otwY9gpYXz2m4KmmvPKAH4VmCaVZKYCaB5TZT+UkgLKShtNt5PqdFHKdChQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-api-design-systems": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-api-design-systems": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.0.0-beta.6.tgz", - "integrity": "sha512-hwSOnUwfZ78+wHXsokB/ho6xOgxK0qnWviSj1QkLvd2bomfP6RM0d4Uk19ND/Mh39oDXDwxiQ7jXHQsU8/Tq/g==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.0.0-beta.11.tgz", + "integrity": "sha512-mQmHfugQLd8pSLj5atOavc2eY7HQVtmnyiL4KOc3xIhtFhjrtXHKkkYh7cGMYCCQCGjE+bcfSyOWu345PtrbPg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.0.0-beta.6.tgz", - "integrity": "sha512-NecW+P4oUgioPW/l1Ang6S76v26QevjTDls+5p0I9a7Kyln8xHbfzYOGH9AEopeygZmhaburC/TO6ochxBZqkw==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.0.0-beta.11.tgz", + "integrity": "sha512-tnEQIkD/SKdWlIBSOnjCjCt34JCIawZBgoOa1ZYJNPchs9pbn014YlHSxpoB+XqMMcQoHphzn1gHrC3Hl2HEMg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-json": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.0.0-beta.6.tgz", - "integrity": "sha512-a2ymHU7BJ11XcZvNpghmUjsyxa+hwf2Jt7MgLIKQGg6Kmnx+pHesx1/ZlqqvhkaKk6ZXbefpK7PTOBcGRerjlQ==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.0.0-beta.11.tgz", + "integrity": "sha512-vN9AY/HL1JxuvXUjiPSBvMlAZSZGUiNDuXjfKotKqsBlnbhfTlVcnwFkshY4V9iomjc3cSg2EV1K6eX7ZKh/sg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^1.0.0-beta.6", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", + "@swagger-api/apidom-ast": "^1.0.0-beta.11", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -5869,144 +5748,144 @@ } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.0.0-beta.6.tgz", - "integrity": "sha512-NgbHpVUMqE81f6rDPU9bO0qbWmiwu7FlrFvBwePktZTbbFaxwt73jFQpqyzKmIumNrg/mCckxzTrbSEW7k85Vw==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.0.0-beta.11.tgz", + "integrity": "sha512-ZcjMGWVDpRaBf5ndoN0nHSBcJazUHqFod8Ug4HIqGpHguQ0Aa2pk22yb9oaAwkg4eFPnNlZROwNXM+ygIxvfRw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-2": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-2": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.0.0-beta.6.tgz", - "integrity": "sha512-cnFcTkzN7xAr6Zal5UnzRRkQpSe3fI910bYs9mjNMUYReo5D+hUyL16PtOf832Qa8vyDlU3WBHqAQuOEk1fepA==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.0.0-beta.11.tgz", + "integrity": "sha512-OOZWoqPSvx+t8vdQ99AnVfQM+nRuCUdArVHkKe7UnPRacdGZ52z1b6diIzTdRl8uGDL1IfMYLRjY5SmmcugwvQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.0.0-beta.6.tgz", - "integrity": "sha512-xkqyXhLWg6iWyriH/t/am3CHFTZOSIUrNP7uSZBHoD6PbvDArYSB+/gtnO7e/NphSSOkqlkRC4+7VTybA9LK+A==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.0.0-beta.11.tgz", + "integrity": "sha512-4hESez5ciPzDNR+Kk6u1gJLD6or19g+D9MQnCmzaVq2Jk6V5Jke/lLW1xOoL2EWCWaz3LClIr5uU5JnQosXntA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.0.0-beta.6.tgz", - "integrity": "sha512-M91gx/QpM6xSf4m2k/OYaPw8Hapir+6KJMEIcLV8aP6UAnb+S2z6XoSLQ633n7QQjLYeLUL0pTzRgU1UPL9cyg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.0.0-beta.11.tgz", + "integrity": "sha512-kk7DZYfhLXLaldOIOiN/bF2RyLkdjrplYT+zDgQj4ESJSFCQF6gD4EdUyiLKjtZoOItu9oQAVneg3AbzBVbj1g==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-2": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-2": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.0.0-beta.6.tgz", - "integrity": "sha512-GdQ8jIgoYaPeIVp3Em5BGi1XwFB+LWa48mKQ7Z/M3S0u1I6YSo7P1iNhm2eRaeoL+LNb7C0ygEwixiJBaSmeew==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.0.0-beta.11.tgz", + "integrity": "sha512-QVataLENINvA9QKdX8cWPezurQXsb8sSU6EzlikpOb/hQLEJMaIGEHYhs3obl0NQNJGdCggrFQRYjo3ocWZtwQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.0.0-beta.6.tgz", - "integrity": "sha512-52guWmqVa9IReg0NRf4KKUZFmlV/fMniJAKk80Xv62XN5X/MduW2P7zln2+FJAA6uGV0rBZip0Zg1McVkPowSw==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.0.0-beta.11.tgz", + "integrity": "sha512-q4wWs4+LHxj1zPzOGqvchtx1lTTnwSjxC9eWkm3zQWLL9wPFCogu4hBY7ojnBqMX0iFnXLt3V9oOG7nHP9IibA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-workflows-json-1": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-json-1/-/apidom-parser-adapter-workflows-json-1-1.0.0-beta.6.tgz", - "integrity": "sha512-B5WW7CSVKjU+1Lt3StUEKgJvaNGF1IHYKg91eH7nvhMfJ/oY6rNpE2+ziVkYETifbZeCWMFqbQYHPzJyqomnQQ==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-json-1/-/apidom-parser-adapter-workflows-json-1-1.0.0-beta.11.tgz", + "integrity": "sha512-DUvYf3yKQQBk0Qy6S4njmj7z81HjAgW9wMNPPLGUid4zyhDTcpLZ1P/ryxH8HNaj8UNxCShCJ09EN6aosh9rZA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-workflows-1": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-workflows-1": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-workflows-yaml-1": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-yaml-1/-/apidom-parser-adapter-workflows-yaml-1-1.0.0-beta.6.tgz", - "integrity": "sha512-2lzE8JemYy998RDlGJ3l4d9SL3Rs1yxEMGC5a/bIml5QVXT2FSu0ohwaxzkX+HB6LbMd1PMlQZ75IJIlxmcb0Q==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-yaml-1/-/apidom-parser-adapter-workflows-yaml-1-1.0.0-beta.11.tgz", + "integrity": "sha512-Kg/qevFBIqVNl5ycSUUABHd76I5TZPF8ohRdhrQ4/zfZHWI44VLLjMDJcJTBg7kxbd3w4lIK7bJo4JvomiuRXQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-ns-workflows-1": "^1.0.0-beta.6", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-ns-workflows-1": "^1.0.0-beta.11", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.0.0-beta.6.tgz", - "integrity": "sha512-iwoSjTdyM4DeYtJEenMEKA51EOsOLxMADOXu/9ixTqMpYghp2GMnkryrtH3mq6oCX+jO3ysADx1dfp/CaukBsg==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.0.0-beta.11.tgz", + "integrity": "sha512-3iiwF87EmYMCzvjCJX5gW+lopH7mhKWKlFuqapZoFG/1VngD3MKyL8Ko+njzAon/dx8g2P/hR2pK5m1brxlEdw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^1.0.0-beta.6", - "@swagger-api/apidom-core": "^1.0.0-beta.6", - "@swagger-api/apidom-error": "^1.0.0-beta.6", + "@swagger-api/apidom-ast": "^1.0.0-beta.11", + "@swagger-api/apidom-core": "^1.0.0-beta.11", + "@swagger-api/apidom-error": "^1.0.0-beta.11", "@tree-sitter-grammars/tree-sitter-yaml": "=0.7.0", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", @@ -6048,13 +5927,13 @@ } }, "node_modules/@swagger-api/apidom-reference": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.6.tgz", - "integrity": "sha512-GdVPd+YAOWdAuJUJ5so63pZ4i0xlBNGClHJfTHirxZbEH9UQjNTKSkQgawUD0UBpg1HeQVzecl1cehoOp/+Uhw==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.11.tgz", + "integrity": "sha512-T3wlUar5spN54ne9UQKVUs6RtDcul1SzkOY51ZL7tlbvb4/Tq+Pjo9/t3LUAOG3eiQ9wlV8spSDKoLF5ATYNOw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^1.0.0-beta.6", + "@swagger-api/apidom-core": "^1.0.0-beta.11", "@types/ramda": "~0.30.0", "axios": "^1.7.4", "minimatch": "^7.4.3", @@ -6114,9 +5993,9 @@ } }, "node_modules/@tanstack/history": { - "version": "1.97.8", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.97.8.tgz", - "integrity": "sha512-+0R1Xe2lRLeK4htiLRCv0fyxDnrCQbK/ltrJ9rofUwMdh/jQ5+tjGBPzOlyUQzTZg7Rzv8NzVzQWuyxjQYga2g==", + "version": "1.98.1", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.98.1.tgz", + "integrity": "sha512-fDu7eOUSUB0MK7YYdCVHQczwJr6p8Fyzso4FKt3xm9MGaRMs9RTmsmk0KQOygGxFOgD44ETavMah5/0JOqFDaw==", "license": "MIT", "engines": { "node": ">=12" @@ -6127,30 +6006,33 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.2.tgz", - "integrity": "sha512-hdO8SZpWXoADNTWXV9We8CwTkXU88OVWRBcsiFrk7xJQnhm6WRlweDzMD+uH+GnuieTBVSML6xFa17C2cNV8+g==", + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.0.tgz", + "integrity": "sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/query-devtools": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.64.2.tgz", - "integrity": "sha512-3DautR5UpVZdk/qNIhioZVF7g8fdQZ1U98sBEEk4Tzz3tihSBNMPgwlP40nzgbPEDBIrn/j/oyyvNBVSo083Vw==", + "version": "5.65.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz", + "integrity": "sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.2.tgz", - "integrity": "sha512-3pakNscZNm8KJkxmovvtZ4RaXLyiYYobwleTMvpIGUoKRa8j8VlrQKNl5W8VUEfVfZKkikvXVddLuWMbcSCA1Q==", + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.0.tgz", + "integrity": "sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==", + "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.64.2" + "@tanstack/query-core": "5.66.0" }, "funding": { "type": "github", @@ -6161,31 +6043,33 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.64.2.tgz", - "integrity": "sha512-+ZjJVnPzc8BUV/Eklu2k9T/IAyAyvwoCHqOaOrk2sbU33LFhM52BpX4eyENXn0bx5LwV3DJZgEQlIzucoemfGQ==", + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.66.0.tgz", + "integrity": "sha512-uB57wA2YZaQ2fPcFW0E9O1zAGDGSbRKRx84uMk/86VyU9jWVxvJ3Uzp+zNm+nZJYsuekCIo2opTdgNuvM3cKgA==", "dev": true, + "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.64.2" + "@tanstack/query-devtools": "5.65.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.64.2", + "@tanstack/react-query": "^5.66.0", "react": "^18 || ^19" } }, "node_modules/@tanstack/react-router": { - "version": "1.97.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.97.14.tgz", - "integrity": "sha512-FQi/Mg8dmpPeLH0Tu7mwoIHhea2WY9DAp5retATxf++y1nv8H6eL0P11RMpL/fMDpr8eoX4OHNY59Zph74q1bA==", + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.98.4.tgz", + "integrity": "sha512-h+NZF0JTeEVqNcwXukiTrPUHGEzW7lRa2zKev+ib726Eedviy+WP9Jw7vIBKjjg8vAjCWyHTKxVo3M5lo74Jog==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.97.8", + "@tanstack/history": "1.98.1", "@tanstack/react-store": "^0.7.0", - "jsesc": "^3.0.2", + "@tanstack/router-core": "^1.98.1", + "jsesc": "^3.1.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, @@ -6197,8 +6081,8 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" } }, "node_modules/@tanstack/react-store": { @@ -6219,10 +6103,27 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/router-core": { + "version": "1.98.1", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.98.1.tgz", + "integrity": "sha512-s0ZBWTB5oIUP/Fb2z0OFK3wZTa831qZzG2ju0hSYhSxwX2d8ZDjXO4aClrGg1tK1O9+ZOb6n+PHdghpF1K9QTg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.98.1", + "@tanstack/store": "^0.7.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/router-devtools": { - "version": "1.97.14", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.97.14.tgz", - "integrity": "sha512-dU1kvvq3ClnU7lz6Kc5PQAK6gIbdYkcihKNaCh4f/8K0RT+8C80ySeq4f+uRED+YLv+IFHdSMQYjOoFh48Q/4w==", + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.98.4.tgz", + "integrity": "sha512-Pa7zRqoKm1mRJ4TbsVOn52MELDMoWRsuWDOEC1j2VQhLFoWIvdZU2ZLRzfZbZ8pMRZcilCKDa4iXfb18IjOEEw==", "dev": true, "license": "MIT", "dependencies": { @@ -6237,19 +6138,19 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.97.14", - "react": ">=18", - "react-dom": ">=18" + "@tanstack/react-router": "^1.98.4", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" } }, "node_modules/@tanstack/router-generator": { - "version": "1.97.14", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.97.14.tgz", - "integrity": "sha512-hNICcT3AsNYJT4bwppwgQTB8pFY5Mvob69TDyDLI28rRg95BtP2gPUzistFGdAxpyQXg+AVNnehpemgdon+FMQ==", + "version": "1.98.6", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.98.6.tgz", + "integrity": "sha512-3TQwtIafN2n3P6ydkp+NlTFWIBRKGY+VTI7g2wo4Ac2XuIhYavgxE/ME4kNoThsytcPinD/qCTvBhYNfKoeAVg==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/virtual-file-routes": "^1.97.8", + "@tanstack/virtual-file-routes": "^1.98.1", "prettier": "^3.4.2", "tsx": "^4.19.2", "zod": "^3.24.1" @@ -6262,7 +6163,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.97.14" + "@tanstack/react-router": "^1.98.4" }, "peerDependenciesMeta": { "@tanstack/react-router": { @@ -6271,32 +6172,27 @@ } }, "node_modules/@tanstack/router-plugin": { - "version": "1.97.14", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.97.14.tgz", - "integrity": "sha512-ZcX6xNJcvfbd5pr9yYfG8LKGY6f8xoiwPOx4O1aVUeBAV3EUOmrpcGwd0jLM5QmrT7YuEUe29O0IRVrP8ABG3g==", + "version": "1.98.6", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.98.6.tgz", + "integrity": "sha512-cMDO6y+eIXY309jzFm9piYRlBRLR9NIVGBtEpIO0r0eaQ6Ns2ue7vRC2Cf70dC5Fb4eo50RIMN/QP11jCOwcsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.0", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", + "@babel/core": "^7.26.7", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@babel/template": "^7.25.9", - "@babel/traverse": "^7.26.4", - "@babel/types": "^7.26.3", - "@tanstack/router-generator": "^1.97.14", - "@tanstack/virtual-file-routes": "^1.97.8", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", + "@tanstack/router-generator": "^1.98.6", + "@tanstack/router-utils": "^1.98.5", + "@tanstack/virtual-file-routes": "^1.98.1", "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.6.8", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.20.6", - "@types/diff": "^6.0.0", "babel-dead-code-elimination": "^1.0.8", - "chalk": "^5.3.0", "chokidar": "^3.6.0", - "diff": "^7.0.0", - "unplugin": "^1.16.0", + "unplugin": "^2.1.2", "zod": "^3.24.1" }, "engines": { @@ -6323,27 +6219,48 @@ } } }, - "node_modules/@tanstack/router-plugin/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@tanstack/router-vite-plugin": { - "version": "1.97.14", - "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.97.14.tgz", - "integrity": "sha512-79i8StKL90vypYcPl80bqr1OslH32mU6c0bcBDnhw5QObDC55uMH0IYwp+ov14si8mSiz7GHZ2DHy5rNvE1WdA==", + "node_modules/@tanstack/router-plugin/node_modules/unplugin": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.1.2.tgz", + "integrity": "sha512-Q3LU0e4zxKfRko1wMV2HmP8lB9KWislY7hxXpxd+lGx0PRInE4vhMBVEZwpdVYHvtqzhSrzuIfErsob6bQfCzw==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/router-plugin": "^1.97.14" + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.98.5", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.98.5.tgz", + "integrity": "sha512-+2G9yqHgh9Xxhw4a5gg5r5WiuXgCQne9j82/RLyKRAG1JK9iGwPc6qPUHQdV359Yf5BO21MvaDeI8iB5y6wTDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.7", + "ansis": "^3.5.2", + "diff": "^7.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-vite-plugin": { + "version": "1.98.6", + "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.98.6.tgz", + "integrity": "sha512-tgjitUnnke4xLiVrB+xrFnvpv6a8ihG4ATI+sVyGOp0MZ+rscIprBTHah51uPq3om/7JcOfTsS+AGAPUybMWJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-plugin": "^1.98.6" }, "engines": { "node": ">=12" @@ -6381,9 +6298,9 @@ } }, "node_modules/@tanstack/virtual-file-routes": { - "version": "1.97.8", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.97.8.tgz", - "integrity": "sha512-wGrY0o997lg/xlMlhhJdJHxdllG+cPXnMb1oeaxijL9wqUUnKwAUERoPeKZRLFhuhfH//4cmXsHF23d0F7qGWA==", + "version": "1.98.1", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.98.1.tgz", + "integrity": "sha512-LBO5HEs6F9oeEgvqmu5ghJSyIQ242KwUJSlxq34AmMYDFOm5m9pVzRMbqbXFKva5CjDQcN8a7SNSy/hYovAwLg==", "dev": true, "license": "MIT", "engines": { @@ -6557,13 +6474,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz", - "integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/doctrine": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", @@ -6609,9 +6519,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "dev": true, "license": "MIT", "dependencies": { @@ -6662,9 +6572,9 @@ "license": "MIT" }, "node_modules/@types/swagger-ui-react": { - "version": "4.18.3", - "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-4.18.3.tgz", - "integrity": "sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-5.18.0.tgz", + "integrity": "sha512-c2M9adVG7t28t1pq19K9Jt20VLQf0P/fwJwnfcmsVVsdkwCWhRmbKDu+tIs0/NGwJ/7GY8lBx+iKZxuDI5gDbw==", "dev": true, "license": "MIT", "dependencies": { @@ -7074,13 +6984,13 @@ } }, "node_modules/@whatwg-node/fetch": { - "version": "0.9.23", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.23.tgz", - "integrity": "sha512-7xlqWel9JsmxahJnYVUj/LLxWcnA93DR4c9xlw3U814jWTiYalryiH1qToik1hOxweKKRLi4haXHM5ycRksPBA==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.3.tgz", + "integrity": "sha512-jCTL/qYcIW2GihbBRHypQ/Us7saWMNZ5fsumsta+qPY0Pmi1ccba/KRQvgctmQsbP69FWemJSs8zVcFaNwdL0w==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/node-fetch": "^0.6.0", + "@whatwg-node/node-fetch": "^0.7.7", "urlpattern-polyfill": "^10.0.0" }, "engines": { @@ -7088,15 +6998,14 @@ } }, "node_modules/@whatwg-node/node-fetch": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.6.0.tgz", - "integrity": "sha512-tcZAhrpx6oVlkEsRngeTEEE7I5/QdLjeEz4IlekabGaESP7+Dkm/6a9KcF1KdCBB7mO9PXtBkwCuTCt8+UPg8Q==", + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.7.tgz", + "integrity": "sha512-BDbIMOenThOTFDBLh1WscgBNAxfDAdAdd9sMG8Ff83hYxApJVbqEct38bUAj+zn8bTsfBx/lyfnVOTyq5xUlvg==", "dev": true, "license": "MIT", "dependencies": { - "@kamilkisiela/fast-url-parser": "^1.1.4", + "@whatwg-node/disposablestack": "^0.0.5", "busboy": "^1.6.0", - "fast-querystring": "^1.1.1", "tslib": "^2.6.3" }, "engines": { @@ -7210,6 +7119,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.10.0.tgz", + "integrity": "sha512-hxDKLYT7hy3Y4sF3HxI926A3urzPxi73mZBB629m9bCVF+NyKNxbwCqqm+C/YrGPtxLwnl6d8/ZASCsz6SyvJA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -9260,13 +9179,6 @@ "url": "https://github.com/sponsors/jaydenseric" } }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -9297,16 +9209,6 @@ "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", "license": "MIT" }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -9463,13 +9365,19 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz", + "integrity": "sha512-kKaIINnFpzW6ffJNDjjyjrk21BkDx38c0xa/klsT8VzLCaMEefv4ZTacrcVR4DmgTeBra++jMDAfS/tS799YDw==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/foreground-child": { @@ -9999,9 +9907,9 @@ } }, "node_modules/happy-dom": { - "version": "16.7.2", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.7.2.tgz", - "integrity": "sha512-zOzw0xyYlDaF/ylwbAsduYZZVRTd5u7IwlFkGbEathIeJMLp3vrN3cHm3RS7PZpD9gr/IO16bHEswcgNyWTsqw==", + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.8.1.tgz", + "integrity": "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw==", "dev": true, "license": "MIT", "dependencies": { @@ -10247,9 +10155,9 @@ } }, "node_modules/i18next": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz", - "integrity": "sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==", + "version": "24.2.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz", + "integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==", "funding": [ { "type": "individual", @@ -11077,9 +10985,9 @@ } }, "node_modules/knip": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.43.1.tgz", - "integrity": "sha512-U910KCyDnQPvXqcIqCRa5y3x9Uww8PcKttyyGb9KSH4uiXCSB/iWMDcbgEFNAqMkJS8S9wAAIWrCOXew5B4dSg==", + "version": "5.43.6", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.43.6.tgz", + "integrity": "sha512-bUCFlg44imdV5vayYxu0pIAB373S8Ufjda0qaI9oRZDH6ltJFwUoAO2j7nafxDmo5G0ZeP4IiLAHqlc3wYIONQ==", "dev": true, "funding": [ { @@ -14110,13 +14018,13 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.5.1.tgz", - "integrity": "sha512-HuaAFA97j2w4i/1EHKj6X4iDiVzPrXzQpmTEE1tLD1QXzqrQKKHse+Ggc8AGMuLTAzxA6xmrX9xibgMNWCgvRA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.5.2.tgz", + "integrity": "sha512-pf84emQ7Pd5jBdT2gzlNs4kRaSI3pq0Lh8lSfV+YqIVXztXIHU+Lqyhek2Lhjb7btzA1tExrhJrgQUsIji7i7A==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core": "8.5.1" + "@storybook/core": "8.5.2" }, "bin": { "getstorybook": "bin/index.cjs", @@ -14441,18 +14349,18 @@ } }, "node_modules/swagger-client": { - "version": "3.33.2", - "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.33.2.tgz", - "integrity": "sha512-LL91X4+KZr3qMdm2knL1ncF104LlmQMNlrlQwm83r793eQiOdB5iuEz1ppdRv/r211vZE66m38VzHclXmqwW7A==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.34.0.tgz", + "integrity": "sha512-DQyg74J1XjpzmoOrSX0/x8OP7feeEzLTQ4ILe15TJ7oTXeC6XKQvnc5z59H5rW7vFxe+rkMlbzLCg/ri0w7Rag==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.22.15", "@scarf/scarf": "=1.4.0", - "@swagger-api/apidom-core": ">=1.0.0-beta.6 <1.0.0-rc.0", - "@swagger-api/apidom-error": ">=1.0.0-beta.6 <1.0.0-rc.0", - "@swagger-api/apidom-json-pointer": ">=1.0.0-beta.6 <1.0.0-rc.0", - "@swagger-api/apidom-ns-openapi-3-1": ">=1.0.0-beta.6 <1.0.0-rc.0", - "@swagger-api/apidom-reference": ">=1.0.0-beta.6 <1.0.0-rc.0", + "@swagger-api/apidom-core": ">=1.0.0-beta.11 <1.0.0-rc.0", + "@swagger-api/apidom-error": ">=1.0.0-beta.11 <1.0.0-rc.0", + "@swagger-api/apidom-json-pointer": ">=1.0.0-beta.11 <1.0.0-rc.0", + "@swagger-api/apidom-ns-openapi-3-1": ">=1.0.0-beta.11 <1.0.0-rc.0", + "@swagger-api/apidom-reference": ">=1.0.0-beta.11 <1.0.0-rc.0", "@swaggerexpert/cookie": "^1.4.1", "deepmerge": "~4.3.0", "fast-json-patch": "^3.0.0-1", @@ -14467,9 +14375,9 @@ } }, "node_modules/swagger-ui-react": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.18.2.tgz", - "integrity": "sha512-vpW7AmkRYdz578iq7C5WrPsg6reBgRzj5xL/fIYR6KTfvY3lvBchpzegFaqg09LWDoL3U2MZvIgOS/1Q9kSJ9g==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.18.3.tgz", + "integrity": "sha512-TlcIdQlcbdvRpUP3+B/J08ARM6cC29eMRrNxhTjP/MtYlbuGg6DWET7Is65YTlsk3TE6NhRYVgf3sdqcLooIBw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.24.7", @@ -14501,7 +14409,7 @@ "reselect": "^5.1.1", "serialize-error": "^8.1.0", "sha.js": "^2.4.11", - "swagger-client": "^3.31.0", + "swagger-client": "^3.34.0", "url-parse": "^1.5.10", "xml": "=1.0.1", "xml-but-prettier": "^1.0.1", @@ -16178,9 +16086,9 @@ } }, "node_modules/vite-plugin-graphql-codegen": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.4.4.tgz", - "integrity": "sha512-khEobnKcWVjJnWcqIWbRIAT1ytSORF0vSgePZUWx8SBc6cqqfw9JjydO71/tMxS6FzsvzYEGCF4Ef3sTtUwOww==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.4.5.tgz", + "integrity": "sha512-+SpN1O7tBzHCsaJz4riNMULhSUd+/bO0gIOrK3uCU6Xtjy62KaHhU2yhsWMe8o6C05Ik36Gukku9LxF0n69rmg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index a249733db..b27596a60 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,8 +22,8 @@ "@fontsource/inter": "^5.1.1", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.5", - "@tanstack/react-query": "^5.64.2", - "@tanstack/react-router": "^1.97.14", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-router": "^1.98.4", "@tanstack/router-zod-adapter": "^1.81.5", "@vector-im/compound-design-tokens": "3.0.1", "@vector-im/compound-web": "^7.6.2", @@ -31,11 +31,11 @@ "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "i18next": "^24.2.1", + "i18next": "^24.2.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.4.0", - "swagger-ui-react": "^5.18.2", + "swagger-ui-react": "^5.18.3", "vaul": "^1.1.2", "zod": "^3.24.1" }, @@ -45,32 +45,32 @@ "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", "@codecov/vite-plugin": "^1.8.0", - "@graphql-codegen/cli": "^5.0.3", + "@graphql-codegen/cli": "^5.0.4", "@graphql-codegen/client-preset": "^4.5.1", "@graphql-codegen/typescript-msw": "^3.0.0", - "@storybook/addon-essentials": "^8.5.1", - "@storybook/addon-interactions": "^8.5.1", - "@storybook/react": "^8.5.1", - "@storybook/react-vite": "^8.5.1", + "@storybook/addon-essentials": "^8.5.2", + "@storybook/addon-interactions": "^8.5.2", + "@storybook/react": "^8.5.2", + "@storybook/react-vite": "^8.5.2", "@storybook/test": "^8.5.0", - "@tanstack/react-query-devtools": "^5.64.2", - "@tanstack/router-devtools": "^1.97.14", - "@tanstack/router-vite-plugin": "^1.97.14", + "@tanstack/react-query-devtools": "^5.66.0", + "@tanstack/router-devtools": "^1.98.4", + "@tanstack/router-vite-plugin": "^1.98.6", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.10.9", + "@types/node": "^22.12.0", "@types/react": "19.0.8", "@types/react-dom": "19.0.3", - "@types/swagger-ui-react": "^4.18.3", + "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.0.4", "autoprefixer": "^10.4.20", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.10.0", - "happy-dom": "^16.7.2", + "happy-dom": "^16.8.1", "i18next-parser": "^9.1.0", - "knip": "^5.43.1", + "knip": "^5.43.6", "msw": "^2.7.0", "msw-storybook-addon": "^2.0.4", "postcss": "^8.5.1", @@ -83,7 +83,7 @@ "typescript": "^5.7.3", "vite": "6.0.11", "vite-plugin-compression": "^0.5.1", - "vite-plugin-graphql-codegen": "^3.4.4", + "vite-plugin-graphql-codegen": "^3.4.5", "vite-plugin-manifest-sri": "^0.2.0", "vitest": "^3.0.1" }, diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 58678a73b..f63f449df 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -337,7 +337,7 @@ type CompatSession implements Node & CreationEvent { """ The Matrix Device ID of this session. """ - deviceId: String! + deviceId: String """ When the object was created. """ diff --git a/frontend/src/components/SessionDetail/SessionDetails.tsx b/frontend/src/components/SessionDetail/SessionDetails.tsx index 3b1775fd0..67430360b 100644 --- a/frontend/src/components/SessionDetail/SessionDetails.tsx +++ b/frontend/src/components/SessionDetail/SessionDetails.tsx @@ -26,7 +26,7 @@ type Props = { title: string | ReactNode; lastActive?: Date; signedIn?: Date; - deviceId?: string; + deviceId?: string | null; ipAddress?: string; scopes?: string[]; details?: Detail[]; diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index c342bd3f4..cf32c2302 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -235,7 +235,7 @@ export type CompatSession = CreationEvent & Node & { /** When the object was created. */ createdAt: Scalars['DateTime']['output']; /** The Matrix Device ID of this session. */ - deviceId: Scalars['String']['output']; + deviceId?: Maybe; /** When the session ended. */ finishedAt?: Maybe; /** ID of the object. */ @@ -1579,7 +1579,7 @@ export type EndBrowserSessionMutation = { __typename?: 'Mutation', endBrowserSes export type OAuth2Client_DetailFragment = { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null, tosUri?: string | null, policyUri?: string | null, redirectUris: Array } & { ' $fragmentName'?: 'OAuth2Client_DetailFragment' }; -export type CompatSession_SessionFragment = { __typename?: 'CompatSession', id: string, createdAt: string, deviceId: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null, deviceType: DeviceType } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } & { ' $fragmentName'?: 'CompatSession_SessionFragment' }; +export type CompatSession_SessionFragment = { __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null, deviceType: DeviceType } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } & { ' $fragmentName'?: 'CompatSession_SessionFragment' }; export type EndCompatSessionMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -1611,7 +1611,7 @@ export type PasswordCreationDoubleInput_SiteConfigFragment = { __typename?: 'Sit export type BrowserSession_DetailFragment = { __typename?: 'BrowserSession', id: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null } | null, lastAuthentication?: { __typename?: 'Authentication', id: string, createdAt: string } | null, user: { __typename?: 'User', id: string, username: string } } & { ' $fragmentName'?: 'BrowserSession_DetailFragment' }; -export type CompatSession_DetailFragment = { __typename?: 'CompatSession', id: string, createdAt: string, deviceId: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } & { ' $fragmentName'?: 'CompatSession_DetailFragment' }; +export type CompatSession_DetailFragment = { __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } & { ' $fragmentName'?: 'CompatSession_DetailFragment' }; export type OAuth2Session_DetailFragment = { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } } & { ' $fragmentName'?: 'OAuth2Session_DetailFragment' }; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 02d3ed55d..db2076e0a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -13,6 +13,7 @@ import { routeTree } from "./routeTree.gen"; // Create a new router instance export const router = createRouter({ routeTree, + scrollRestoration: true, basepath: config.root, defaultPendingComponent: LoadingScreen, defaultPreload: "intent", diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 21ca5d8e2..a4ab23b30 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -9,7 +9,6 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { type ErrorRouteComponent, Outlet, - ScrollRestoration, createRootRouteWithContext, } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; @@ -28,7 +27,6 @@ export const Route = createRootRouteWithContext<{ }>()({ component: () => ( <> - {import.meta.env.DEV && diff --git a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap index 19666f3b4..5964df356 100644 --- a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap +++ b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap @@ -48,6 +48,9 @@ exports[`Reset cross signing > renders the cancelled page 1`] = `

+ `; @@ -234,6 +237,9 @@ exports[`Reset cross signing > renders the deep link page 1`] = `

+ `; @@ -322,6 +328,9 @@ exports[`Reset cross signing > renders the errored page 1`] = `

+ `; @@ -508,6 +517,9 @@ exports[`Reset cross signing > renders the page 1`] = `

+ `; @@ -554,6 +566,9 @@ exports[`Reset cross signing > renders the success page 1`] = `

+ `; @@ -600,5 +615,8 @@ exports[`Reset cross signing > renders the success page 2`] = `

+ `; diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index 48fa1e3be..3b0948a4e 100644 --- a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap +++ b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap @@ -736,5 +736,8 @@ exports[`Account home page > renders the page 1`] = `

+ `; diff --git a/misc/sqlx_update.sh b/misc/sqlx_update.sh new file mode 100755 index 000000000..6dee03da4 --- /dev/null +++ b/misc/sqlx_update.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +if [ "${DATABASE_URL+defined}" != defined ]; then + echo "You need to set DATABASE_URL" + exit 1 +fi + +if [ "$DATABASE_URL" = "postgres:" ]; then + # Hacky, but psql doesn't accept `postgres:` on its own like sqlx does + export DATABASE_URL="postgres:///" +fi + +crates_dir=$(dirname $(realpath $0))"/../crates" + +CRATES_WITH_SQLX="storage-pg syn2mas" + +for crate in $CRATES_WITH_SQLX; do + echo "=== Updating sqlx query info for $crate ===" + + if [ $crate = syn2mas ]; then + # We need to apply the syn2mas_temporary_tables.sql one-off 'migration' + # for checking the syn2mas queries + + # not evident from the help text, but psql accepts connection URLs as the dbname + psql --dbname="$DATABASE_URL" --single-transaction --file="${crates_dir}/syn2mas/src/mas_writer/syn2mas_temporary_tables.sql" + fi + + (cd "$crates_dir/$crate" && cargo sqlx prepare) || echo "(failed to prepare for $crate)" + + if [ $crate = syn2mas ]; then + # Revert syn2mas temporary tables + psql --dbname="$DATABASE_URL" --single-transaction --file="${crates_dir}/syn2mas/src/mas_writer/syn2mas_revert_temporary_tables.sql" + fi +done diff --git a/tools/syn2mas/package-lock.json b/tools/syn2mas/package-lock.json index 5062d1ee2..9032888d9 100644 --- a/tools/syn2mas/package-lock.json +++ b/tools/syn2mas/package-lock.json @@ -670,10 +670,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", - "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "version": "22.10.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz", + "integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.20.0" }