diff --git a/.github/workflows/rust-sdk.yml b/.github/workflows/rust-sdk.yml new file mode 100644 index 0000000..1708d99 --- /dev/null +++ b/.github/workflows/rust-sdk.yml @@ -0,0 +1,47 @@ +name: Rust SDK +on: + pull_request: + paths: ["rust/**", "Cargo.toml", "Cargo.lock", ".github/workflows/rust-sdk.yml", "scripts/check_rust_parity.py", "typescript/src/**"] + push: + branches: [main] + paths: ["rust/**", "Cargo.toml", "Cargo.lock", ".github/workflows/rust-sdk.yml", "scripts/check_rust_parity.py", "typescript/src/**"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: rust-sdk-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + runs-on: ubuntu-22.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - name: Install Rust toolchain + run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy + - name: Check formatting + run: cargo +stable fmt --all -- --check + - name: Check API presence parity + run: python3 scripts/check_rust_parity.py + - name: Check all targets + run: cargo +stable clippy --workspace --all-targets --all-features --locked -- -D warnings + - name: Test SDK and examples + if: ${{ !cancelled() }} + run: cargo +stable test --workspace --all-features --locked + - name: Build documentation + if: ${{ !cancelled() }} + env: + RUSTDOCFLAGS: -D warnings + run: cargo +stable doc --workspace --all-features --no-deps --locked + - name: Check crate packaging + if: ${{ !cancelled() }} + run: cargo +stable package --manifest-path rust/Cargo.toml --locked + msrv: + runs-on: ubuntu-22.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - name: Install minimum Rust + run: rustup toolchain install 1.89.0 --profile minimal + - name: Check minimum supported Rust + run: cargo +1.89.0 check --workspace --all-targets --all-features --locked diff --git a/.gitignore b/.gitignore index 585d19c..eda760b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.tgz version.sh +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1dc7fb3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2102 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "ring", + "serde", + "serde_json", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "indexmap", + "libc", + "pin-project-lite", + "reqwest 0.13.5", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "sentroy-client-sdk" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "futures-util", + "hmac", + "jsonwebtoken", + "percent-encoding", + "rand 0.8.8", + "reqwest 0.12.28", + "reqwest 0.13.5", + "rmcp", + "serde", + "serde_json", + "sha2", + "sse-stream", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d50b2af --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["rust"] +resolver = "2" diff --git a/README.md b/README.md index 4524681..899dd21 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ | Go | [`github.com/Sentroy-Co/client-sdk/go`](https://pkg.go.dev/github.com/Sentroy-Co/client-sdk/go) | `go get github.com/Sentroy-Co/client-sdk/go` | | Python | [`sentroy-client-sdk`](https://pypi.org/project/sentroy-client-sdk/) | `pip install sentroy-client-sdk` | | PHP | [`sentroy-co/client-sdk`](https://packagist.org/packages/sentroy-co/client-sdk) | `composer require sentroy-co/client-sdk` | +| Rust | [`sentroy-client-sdk`](rust/) | [Git or path dependency](rust/README.md) — crates.io release pending | | cURL | — | [Examples](curl/) | -All SDKs target the same API surface (mail + storage) and work against either the hosted platform (`https://sentroy.com`) or your own Sentroy deployment — only the `baseUrl` changes. +All SDKs target the Mail and Storage APIs against the hosted platform (`https://sentroy.com`) or your own Sentroy deployment. The Rust SDK also covers WhatsApp, Identity, Env Vault and MCP; see each SDK's reference for its supported surface. ## Authentication @@ -43,6 +44,7 @@ Each SDK ships a single comprehensive `AGENTS.md` reference — feed the raw URL ``` https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/typescript/AGENTS.md +https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/rust/AGENTS.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/go/README.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/python/README.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/php/README.md @@ -59,7 +61,7 @@ https://docs.sentroy.com/skill.md # Anthropic Skill format https://docs.sentroy.com/agents.md # universal markdown ``` -> Only the TypeScript SDK has split `README.md` (lean, npm-facing) from `AGENTS.md` (full reference). The Go, Python, PHP and cURL READMEs remain comprehensive and double as agent-facing docs. +> TypeScript and Rust have split `README.md` (lean, npm-facing) from `AGENTS.md` (full reference). The Go, Python, PHP and cURL READMEs remain comprehensive and double as agent-facing docs. ## License diff --git a/rust/AGENTS.md b/rust/AGENTS.md new file mode 100644 index 0000000..8417006 --- /dev/null +++ b/rust/AGENTS.md @@ -0,0 +1,9 @@ +# Rust SDK maintenance + +This directory contains a native async client; consult [README.md](README.md) and [PARITY.md](PARITY.md) before changing its public surface. + +- The TypeScript comparison source and 70 root resource mappings are recorded in [api-parity.json](api-parity.json). Update the manifest when changing a mapped method; run `python3 ../scripts/check_rust_parity.py`. This guard checks presence, not behavior. +- Preserve the separate REST, Identity, Vault and MCP credential namespaces. Do not put tokens, private file contents or raw sensitive server errors in logs or examples. +- Preserve BCC request IDs on uncertain outcomes. Do not automatically retry or redirect compose requests, MCP writes, or private transfers. +- Test wire behavior using local fixtures. From this directory run `cargo fmt --check`, `cargo test`, `cargo check --examples`, and the parity guard. CI verifies Rust 1.89 as the minimum, with a committed lockfile. Run `cargo clippy --all-targets --all-features --locked -- -D warnings`, `cargo doc --no-deps --locked`, and `cargo package --locked` before releases. +- Native code does not provide React, browser storage, WebAuthn execution or the TypeScript CLI. Document platform boundaries rather than claiming those interfaces exist. diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..a5cb071 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "sentroy-client-sdk" +version = "0.1.0" +edition = "2021" +rust-version = "1.89" +description = "Async Rust client for Sentroy Mail, Storage, WhatsApp, Auth, Vault and MCP" +license = "MIT" +repository = "https://github.com/Sentroy-Co/client-sdk" +homepage = "https://docs.sentroy.com" +readme = "README.md" +keywords = ["sentroy", "sdk", "email", "storage", "mcp"] +categories = ["api-bindings", "web-programming::http-client"] + +[dependencies] +base64 = "0.22" +bytes = "1" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +futures-util = "0.3" +hmac = "0.12" +jsonwebtoken = { version = "9.3.1", default-features = false } +percent-encoding = "2" +rand = "0.8" +reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "json", "multipart", "stream"] } +reqwest13 = { package = "reqwest", version = "0.13.2", default-features = false, features = ["rustls"] } +rmcp = { version = "3.8.0", default-features = false, features = ["client", "transport-streamable-http-client-reqwest", "reqwest"] } +sse-stream = "0.2.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["sync", "time", "fs", "io-util", "rt"] } +url = "2" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util"] } diff --git a/rust/LICENSE b/rust/LICENSE new file mode 100644 index 0000000..402d971 --- /dev/null +++ b/rust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sentroy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/PARITY.md b/rust/PARITY.md new file mode 100644 index 0000000..00258ad --- /dev/null +++ b/rust/PARITY.md @@ -0,0 +1,31 @@ +# Rust API coverage + +The comparison source is TypeScript commit `326f131dec57c8e8a4dd6b2f8eb18020a772e861`. The [manifest](api-parity.json) maps each of the 70 root resource methods to a native Rust method. Method names use Rust's snake_case; `Inbox::move_message` corresponds to `move`. + +| Resource | Count | Rust operations | +| --- | ---: | --- | +| Domains | 2 | list, get | +| Mailboxes | 1 | list | +| Templates | 5 | list, get, create, update, delete | +| Inbox | 9 | save_draft, list, get, list_folders, get_thread, mark_as_read, mark_as_unread, move_message, delete | +| Send | 1 | email | +| Audience | 13 | contacts: list, search, get, create, update, delete; lists: list, get, create, delete; members: list, add, remove | +| Suppressions | 3 | list, add, remove | +| Webhooks | 9 | list, get, create, update, delete, test; deliveries: list, get, replay | +| Logs | 2 | list, get | +| Buckets | 5 | list, get, create, update, delete | +| Media | 5 | list, get, upload, delete, download | +| Storage | 2 | quota, usage | +| WhatsApp | 13 | numbers: list; templates and audiences: list, get, create, update, delete; logs: list; send | +| **Total** | **70** | | + +Synchronous scoped factories such as `audience.lists.members(id)` and `webhooks.deliveries(id)` are additional helpers. The manifest and `python3 ../scripts/check_rust_parity.py` check public method presence against the current TypeScript resource source; they do not establish behavioral equivalence. Contract tests are separate. The guard does not cover the independent modules below. + +| Surface | Native scope and differences | +| --- | --- | +| Auth | Identity sign-up, sign-in, MFA, sessions, password/email/account operations, activity, social URL, passkey begin/complete, and admin operations; some calls expose JSON values and selected typed alternatives. Sessions are in memory; native applications own persistence and refresh scheduling. The application supplies a validated callback fragment to `consume_redirect_fragment`; browser navigation, callback context checks and WebAuthn execution remain application responsibilities. Admin user listing is unimplemented in both source SDKs. | +| Vault | Instance-scoped configuration, cached reads, explicit environment fallback, public filtering, invalidation, refresh and signed webhook verification. Clones share cache state; independent clients do not. | +| MCP | Official `rmcp` connection, resource/metadata helpers, static or dynamic credential provider, and private download/upload helpers. Official protocol buffering and errors are separate from bounded private-file transfers. No automatic consent, token acquisition, redirect following or retry. | +| Browser and CLI | TypeScript React and React Native components, hooks, browser storage, UI and CLI do not have direct Rust equivalents. | + +Identity, company REST, Vault and MCP credentials are distinct. Sensitive session and file data should remain in application-controlled storage. For recovery and file limits see [README.md](README.md). diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..fa23ee3 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,54 @@ +# Sentroy Rust SDK + +Async native client for Sentroy Mail, Storage, WhatsApp, Identity, Env Vault and MCP. This crate is **not published to crates.io**. Pin a reviewed Git commit, or use a local checkout: + +```toml +[dependencies] +sentroy-client-sdk = { git = "https://github.com/Sentroy-Co/client-sdk.git", rev = "", package = "sentroy-client-sdk" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +For a local checkout use `sentroy-client-sdk = { path = "../client-sdk/rust" }`. Rust 1.89 or newer is required and checked in CI. Examples use Tokio. Run `cargo check --examples` and `cargo test` from this directory. + +## Mail, storage and WhatsApp + +```rust,no_run +use sentroy_client_sdk::Sentroy; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Sentroy::new( + "https://sentroy.com", + std::env::var("SENTROY_COMPANY_SLUG")?, + std::env::var("SENTROY_API_KEY")?, + )?; + let domains = client.domains.list().await?; + let buckets = client.buckets.list().await?; + println!("{} domains; {} buckets", domains.len(), buckets.len()); + Ok(()) +} +``` + +See [examples/rest.rs](examples/rest.rs) for an executable version. `Sentroy::builder(slug)` configures the base URL, token and timeout; its default timeout is 30 seconds. A token may be omitted, but the native client does not manage a browser cookie session. The root client routes through `/api/{mail,storage,whatsapp}/companies/{slug}`. Request and response models are in `types::{mail,storage,whatsapp}`; optional fields use `Option`. + +Errors implement `std::error::Error`. `status_code()` and `body()` expose available API error details; response bodies can contain sensitive data and should not be logged blindly. For BCC send and draft operations the SDK uses a versioned endpoint and returns an error containing `compose_request_id()` on an uncertain outcome. Save that UUID and resolve or retry the same operation with the **same** ID; do not generate a fresh one after an unknown result. These requests use a 4 KiB receipt limit and do not follow redirects or retry automatically. Other JSON API responses are capped at 16 MiB. Non-BCC multi-recipient sends are normalized into personalized recipient entries. + +## Identity and Vault + +Identity and Vault are separate clients with separate credentials. [examples/identity_vault.rs](examples/identity_vault.rs) demonstrates a typed sign-in and a Vault lookup. Identity sessions are held in memory; clones share a session and serialize refreshes. The application owns secure persistence, refresh scheduling, browser redirects and any native WebAuthn integration. Passkey begin/complete calls expose protocol data; they do not invoke an authenticator. MFA can be returned instead of login tokens. Typed requests and responses cover signup, login, refresh, MFA, password reset, invitations and profile operations; raw JSON methods remain available for evolving server fields. `consume_redirect_fragment` accepts a callback fragment supplied and validated by your application, verifies the user through `/me`, and establishes the session. The admin user-list operation is unimplemented, matching the TypeScript source. + +`VaultClient::new(VaultConfig::from_env())` reads `SENTROY_ENV_API_KEY` and the documented base URL environment variables. Its default cache TTL is five minutes and timeout is five seconds; clones share a cache. `get_with_fallback` explicitly falls back to the process environment on a miss or Vault failure. `public()` filters to variables marked public; `all()` includes private values. Verify Vault webhook signatures over the raw request bytes with `verify_webhook` or `handle_webhook` before processing, using `DEFAULT_WEBHOOK_MAX_AGE` for the five-minute replay window. `handle_webhook` invalidates lazily; call `refresh()` for an immediate fetch. + +## MCP + +[examples/mcp.rs](examples/mcp.rs) connects with an existing `mcp_` or `mcp_at_` token, exposes the official `rmcp` client, and closes the connection. Protocol model types are re-exported under `mcp::protocol`; `resource()` reports the immutable protected resource. MCP credentials are distinct from company `stk_`, Vault `stk_env_`, and Identity credentials. The SDK does not obtain consent, acquire or store tokens, or initiate tool calls on connection. Follow server approval requirements for writes. + +The protocol transport uses official `rmcp`: its SSE event limit is 1 MiB, but other protocol response buffering and errors follow the upstream client and are not covered by the private-file limits below. Treat server-provided content and error text as untrusted and potentially sensitive. Automatic redirects, reconnect retries, and session reinitialization are disabled. + +Private file helpers validate references and bind them to the connection, enforce size limits and avoid redirects or automatic retries. Storage downloads are bounded to 64 MiB, mail attachments to 8 MiB decoded (12 MiB encoded attachment metadata limit), contact files to 1 MiB, and storage uploads to 10 MiB. Downloads default to 60 seconds and uploads to 15 seconds, including the body; `TransferOptions` allows 1 millisecond–120 seconds. Lost upload acknowledgements require checking the existing operation status rather than starting a new one. See [PARITY.md](PARITY.md) for scope. + +## Native boundaries and validation + +React components, hooks, browser storage, cookie authentication, automatic browser redirects and the TypeScript CLI are not provided. The native SDK exposes portable service operations and leaves platform UI and credential storage to the application. + +`python3 ../scripts/check_rust_parity.py` compares the TypeScript root resource method inventory with Rust public methods. It checks **presence**, not serialization, behavior or security parity. Unit and loopback tests provide separate contract checks; see [PARITY.md](PARITY.md). diff --git a/rust/api-parity.json b/rust/api-parity.json new file mode 100644 index 0000000..ec2ba19 --- /dev/null +++ b/rust/api-parity.json @@ -0,0 +1,846 @@ +{ + "reference_commit": "326f131dec57c8e8a4dd6b2f8eb18020a772e861", + "description": "Root TypeScript resource async methods mapped to Rust public async methods. Checks API presence, not behavioral equivalence.", + "methods": [ + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceListMembers", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "ListMembers", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceListMembers", + "method": "add" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "ListMembers", + "method": "add" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceListMembers", + "method": "remove" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "ListMembers", + "method": "remove" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "AudienceLists", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "AudienceLists", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "AudienceLists", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "AudienceLists", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Contacts", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "search" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Contacts", + "method": "search" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Contacts", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Contacts", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "update" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Contacts", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Contacts", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Buckets", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Buckets", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Buckets", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "update" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Buckets", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Buckets", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/domains.ts", + "class": "Domains", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Domains", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/domains.ts", + "class": "Domains", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Domains", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "saveDraft" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "save_draft" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "listFolders" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "list_folders" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "getThread" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "get_thread" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "markAsRead" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "mark_as_read" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "markAsUnread" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "mark_as_unread" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "move" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "move_message" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Inbox", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/logs.ts", + "class": "Logs", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Logs", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/logs.ts", + "class": "Logs", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Logs", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/mailboxes.ts", + "class": "Mailboxes", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Mailboxes", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "MediaResource", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "MediaResource", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "upload" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "MediaResource", + "method": "upload" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "MediaResource", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "download" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "MediaResource", + "method": "download" + } + }, + { + "typescript": { + "file": "typescript/src/resources/send.ts", + "class": "Send", + "method": "email" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Send", + "method": "email" + } + }, + { + "typescript": { + "file": "typescript/src/resources/storage.ts", + "class": "Storage", + "method": "quota" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Storage", + "method": "quota" + } + }, + { + "typescript": { + "file": "typescript/src/resources/storage.ts", + "class": "Storage", + "method": "usage" + }, + "rust": { + "file": "rust/src/resources/storage.rs", + "type": "Storage", + "method": "usage" + } + }, + { + "typescript": { + "file": "typescript/src/resources/suppressions.ts", + "class": "Suppressions", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Suppressions", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/suppressions.ts", + "class": "Suppressions", + "method": "add" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Suppressions", + "method": "add" + } + }, + { + "typescript": { + "file": "typescript/src/resources/suppressions.ts", + "class": "Suppressions", + "method": "remove" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Suppressions", + "method": "remove" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Templates", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Templates", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Templates", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "update" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Templates", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Templates", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "WebhookDeliveries", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "WebhookDeliveries", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "WebhookDeliveries", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "WebhookDeliveries", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "WebhookDeliveries", + "method": "replay" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "WebhookDeliveries", + "method": "replay" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Webhooks", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Webhooks", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Webhooks", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "update" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Webhooks", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Webhooks", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "test" + }, + "rust": { + "file": "rust/src/resources/mail.rs", + "type": "Webhooks", + "method": "test" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppNumbers", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppNumbers", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppTemplates", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppTemplates", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppTemplates", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "update" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppTemplates", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppTemplates", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppAudiences", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "get" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppAudiences", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "create" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppAudiences", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "update" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppAudiences", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "delete" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppAudiences", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppLogs", + "method": "list" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsAppLogs", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsApp", + "method": "send" + }, + "rust": { + "file": "rust/src/resources/whatsapp.rs", + "type": "WhatsApp", + "method": "send" + } + } + ] +} diff --git a/rust/examples/identity_vault.rs b/rust/examples/identity_vault.rs new file mode 100644 index 0000000..0436b21 --- /dev/null +++ b/rust/examples/identity_vault.rs @@ -0,0 +1,29 @@ +use sentroy_client_sdk::auth::{SentroyAuth, SignInRequest, TypedLoginOutcome}; +use sentroy_client_sdk::vault::{VaultClient, VaultConfig}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let identity = SentroyAuth::new( + "https://auth.sentroy.com", + std::env::var("SENTROY_AUTH_PROJECT_SLUG")?, + std::env::var("SENTROY_AUTH_API_KEY").ok(), + Duration::from_secs(30), + )?; + let outcome = identity + .sign_in_typed(&SignInRequest { + email: std::env::var("SENTROY_AUTH_EMAIL")?, + password: std::env::var("SENTROY_AUTH_PASSWORD")?, + remember_me: None, + }) + .await?; + match outcome { + TypedLoginOutcome::Tokens(_) => println!("Signed in"), + TypedLoginOutcome::Mfa(_) => println!("MFA verification required"), + } + + let vault = VaultClient::new(VaultConfig::from_env())?; + let present = vault.get("MY_SETTING").await?.is_some(); + println!("MY_SETTING is configured: {present}"); + Ok(()) +} diff --git a/rust/examples/mcp.rs b/rust/examples/mcp.rs new file mode 100644 index 0000000..832af4c --- /dev/null +++ b/rust/examples/mcp.rs @@ -0,0 +1,15 @@ +use sentroy_client_sdk::mcp::{connect_sentroy_mcp, McpApp, McpResourceOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let options = McpResourceOptions { + app: McpApp::Mail, + company_slug: std::env::var("SENTROY_COMPANY_SLUG")?, + origin: None, + }; + let token = std::env::var("SENTROY_MCP_TOKEN")?; + let mut connection = connect_sentroy_mcp(&options, &token).await?; + // Use connection.client.peer() for protocol operations; writes may require approval. + connection.close().await?; + Ok(()) +} diff --git a/rust/examples/rest.rs b/rust/examples/rest.rs new file mode 100644 index 0000000..37c7823 --- /dev/null +++ b/rust/examples/rest.rs @@ -0,0 +1,14 @@ +use sentroy_client_sdk::Sentroy; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Sentroy::new( + "https://sentroy.com", + std::env::var("SENTROY_COMPANY_SLUG")?, + std::env::var("SENTROY_API_KEY")?, + )?; + let domains = client.domains.list().await?; + let buckets = client.buckets.list().await?; + println!("{} domains; {} buckets", domains.len(), buckets.len()); + Ok(()) +} diff --git a/rust/src/auth/admin.rs b/rust/src/auth/admin.rs new file mode 100644 index 0000000..bd1d010 --- /dev/null +++ b/rust/src/auth/admin.rs @@ -0,0 +1,247 @@ +use super::{ + client::auth_error, AuthTokensResponse, LoginOutcome, LoginResponse, MfaVerifyRequest, + SentroyAuth, SignInRequest, SignupRequest, SignupResponse, TypedLoginOutcome, +}; +use crate::{Error, Result}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde_json::{json, Value}; +use std::{ + sync::Arc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Mutex; + +/// Server-side, request-scoped administrator. Keep the project API key secret. +#[derive(Clone)] +pub struct SentroyAuthAdmin { + client: SentroyAuth, + api_key: String, + jwks_ttl: Duration, + jwks: Arc>>, +} +impl std::fmt::Debug for SentroyAuthAdmin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SentroyAuthAdmin") + .field("client", &self.client) + .field("api_key", &"[REDACTED]") + .finish() + } +} +impl SentroyAuthAdmin { + pub fn new( + base: impl Into, + project_slug: impl Into, + api_key: impl Into, + timeout: Duration, + jwks_ttl: Duration, + ) -> Result { + let api_key = api_key.into(); + if api_key.is_empty() { + return Err(Error::InvalidInput("API key must not be empty".into())); + } + let client = SentroyAuth::new(base, project_slug, Some(api_key.clone()), timeout)?; + Ok(Self { + client, + api_key, + jwks_ttl, + jwks: Arc::new(Mutex::new(None)), + }) + } + fn path(&self, p: &str) -> String { + self.client.path(p) + } + async fn post(&self, p: &str, body: Value) -> Result { + self.client + .http + .post(&self.path(p), body) + .await + .map_err(auth_error) + } + pub async fn create_user(&self, input: Value) -> Result { + self.post("/signup", input).await + } + pub async fn create_user_typed(&self, input: &SignupRequest) -> Result { + Ok(serde_json::from_value( + self.create_user(serde_json::to_value(input)?).await?, + )?) + } + pub async fn sign_in_typed(&self, input: &SignInRequest) -> Result { + match self.sign_in(serde_json::to_value(input)?).await? { + LoginOutcome::Tokens(v) => Ok(TypedLoginOutcome::Tokens(Box::new( + serde_json::from_value(v)?, + ))), + LoginOutcome::Mfa(v) => Ok(TypedLoginOutcome::Mfa(serde_json::from_value(v)?)), + } + } + pub async fn sign_in(&self, input: Value) -> Result { + let v = self.post("/login", input).await?; + if v.get("mfaRequired") == Some(&Value::Bool(true)) { + Ok(LoginOutcome::Mfa(v)) + } else { + Ok(LoginOutcome::Tokens(v)) + } + } + pub async fn verify_mfa(&self, input: Value) -> Result { + self.post("/login/mfa/verify", input).await + } + pub async fn verify_mfa_typed(&self, input: &MfaVerifyRequest) -> Result { + Ok(serde_json::from_value( + self.verify_mfa(serde_json::to_value(input)?).await?, + )?) + } + pub async fn refresh_typed(&self, token: &str) -> Result { + Ok(serde_json::from_value(self.refresh(token).await?)?) + } + pub async fn refresh(&self, token: &str) -> Result { + self.post("/refresh", json!({"refreshToken":token})).await + } + pub async fn sign_out(&self, token: &str) -> Result { + self.post("/logout", json!({"refreshToken":token})).await + } + pub async fn verify_email(&self, token: &str) -> Result { + self.post("/verify-email", json!({"token":token})).await + } + pub async fn request_password_reset(&self, email: &str) -> Result { + self.post("/password-reset/request", json!({"email":email})) + .await + } + pub async fn confirm_password_reset(&self, input: Value) -> Result { + self.post("/password-reset/confirm", input).await + } + pub async fn send_magic_link(&self, input: Value) -> Result { + self.post("/magic-link/request", input).await + } + pub async fn consume_magic_link(&self, token: &str) -> Result { + self.post("/magic-link/consume", json!({"token":token})) + .await + } + pub async fn accept_invitation(&self, input: Value) -> Result { + self.post("/invitation/accept", input).await + } + pub async fn get_user(&self, token: &str) -> Result { + self.client + .http + .with_token(Some(token.to_owned()))? + .get(&self.path("/me"), &[]) + .await + .map_err(auth_error) + } + pub async fn get_userinfo(&self, token: &str) -> Result { + self.client + .http + .with_token(Some(token.to_owned()))? + .get(&self.path("/userinfo"), &[]) + .await + .map_err(auth_error) + } + /// The reference explicitly has no public admin user-list endpoint. + pub fn list_users(&self) -> Result { + Err(Error::InvalidInput("admin users.list requires the session-authenticated dashboard API; no public API is available".into())) + } + pub async fn invalidate_jwks_cache(&self) { + *self.jwks.lock().await = None; + } + async fn jwks(&self) -> Result { + let mut cache = self.jwks.lock().await; + if let Some((at, keys)) = cache.as_ref() { + if at.elapsed() < self.jwks_ttl { + return Ok(keys.clone()); + } + } + // Always use the configured auth origin; never follow a JWT-supplied URL. + let v: Value = self + .client + .http + .get(&self.path("/jwks.json"), &[]) + .await + .map_err(auth_error)?; + *cache = Some((Instant::now(), v.clone())); + Ok(v) + } + /// Verify an RS256 signature against the configured project's JWKS, then + /// require the configured issuer and the reference API-key audience rule. + pub async fn verify_id_token(&self, token: &str) -> Result { + let invalid = |s: &str| Error::InvalidResponse(s.to_owned()); + let header = decode_header(token).map_err(|_| invalid("Malformed JWT"))?; + if header.alg != Algorithm::RS256 { + return Err(invalid("Only RS256 supported")); + } + let jwks = self.jwks().await?; + let keys = jwks + .get("keys") + .and_then(Value::as_array) + .ok_or_else(|| invalid("Malformed JWKS"))?; + let kid = header + .kid + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| invalid("Missing key id"))?; + let mut matching = keys + .iter() + .filter(|k| k.get("kid").and_then(Value::as_str) == Some(kid)); + let key = matching.next().ok_or_else(|| invalid("Unknown key id"))?; + if matching.next().is_some() { + return Err(invalid("Ambiguous key id")); + } + if key.get("alg").is_some_and(|v| v.as_str() != Some("RS256")) + || key.get("use").is_some_and(|v| v.as_str() != Some("sig")) + { + return Err(invalid("JWKS key is not an RS256 signing key")); + } + if let Some(ops) = key.get("key_ops") { + if !ops + .as_array() + .is_some_and(|ops| ops.iter().any(|v| v.as_str() == Some("verify"))) + { + return Err(invalid("JWKS key cannot verify")); + } + } + if key.get("kty").and_then(Value::as_str) != Some("RSA") { + return Err(invalid("JWKS key is not RSA")); + } + let n = key + .get("n") + .and_then(Value::as_str) + .ok_or_else(|| invalid("Missing RSA modulus"))?; + let e = key + .get("e") + .and_then(Value::as_str) + .ok_or_else(|| invalid("Missing RSA exponent"))?; + let decoding_key = + DecodingKey::from_rsa_components(n, e).map_err(|_| invalid("Invalid RSA key"))?; + let mut validation = Validation::new(Algorithm::RS256); + // Validate the exact reference semantics below, after signature verification. + validation.required_spec_claims.clear(); + validation.validate_exp = false; + validation.validate_aud = false; + let claims = decode::(token, &decoding_key, &validation) + .map_err(|_| invalid("JWT signature or format invalid"))? + .claims; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| invalid("System clock before epoch"))? + .as_secs_f64(); + let exp = claims + .get("exp") + .and_then(Value::as_f64) + .ok_or_else(|| invalid("Token expired"))?; + if !exp.is_finite() || exp < now { + return Err(invalid("Token expired")); + } + let iss = claims + .get("iss") + .and_then(Value::as_str) + .ok_or_else(|| invalid("Issuer mismatch"))?; + if iss != format!("{}/p/{}", self.client.base, self.client.slug) { + return Err(invalid("Issuer mismatch")); + } + let aud = claims + .get("aud") + .and_then(Value::as_str) + .ok_or_else(|| invalid("Audience mismatch"))?; + if self.api_key.get(..12) != Some(aud) { + return Err(invalid("Audience mismatch")); + } + Ok(claims) + } +} diff --git a/rust/src/auth/client.rs b/rust/src/auth/client.rs new file mode 100644 index 0000000..a168a60 --- /dev/null +++ b/rust/src/auth/client.rs @@ -0,0 +1,610 @@ +use super::{ + AcceptInvitationRequest, ActivityEntry, AuthTokensResponse, AuthUser, ChangeEmailRequest, + ChangePasswordRequest, LoginOutcome, LoginResponse, MagicLinkRequest, MfaEnrollment, + MfaEnrollmentVerification, MfaStatus, MfaVerifyRequest, PasskeyBegin, PasskeySummary, + PasswordResetConfirmRequest, Session, SessionSummary, SignInRequest, SignupRequest, + SignupResponse, TypedLoginOutcome, UserResponse, +}; +use crate::{ + http::{segment, Http}, + Error, Result, +}; +use serde_json::{json, Value}; +use std::{ + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::Mutex; + +/// Portable identity client. No browser, local storage, timers or WebAuthn runtime is supplied. +/// Clones share one in-memory session and serialize refreshes to avoid rotating a token twice. +#[derive(Clone)] +pub struct SentroyAuth { + pub(crate) http: Http, + pub(crate) base: String, + pub(crate) slug: String, + slug_encoded: String, + state: Arc>, + epoch: Arc, +} +#[derive(Default)] +struct State { + session: Option, + // Only a caller that observed this exact generation before waiting may reuse it. + last_refresh: Option<(u64, Value)>, +} +impl std::fmt::Debug for SentroyAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SentroyAuth") + .field("base", &self.base) + .field("slug", &self.slug) + .field("session", &"[REDACTED]") + .finish() + } +} +pub(crate) fn auth_error(e: Error) -> Error { + match e { + Error::Http { + status, + message: _, + body, + } => Error::Auth { + code: body + .get("error") + .and_then(Value::as_str) + .unwrap_or("http_error") + .to_string(), + description: body + .get("error_description") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("HTTP {status}")), + status: Some(status), + }, + e => e, + } +} +impl SentroyAuth { + pub fn new( + base: impl Into, + project_slug: impl Into, + api_key: Option, + timeout: Duration, + ) -> Result { + let base = base.into().trim_end_matches('/').to_string(); + let slug = project_slug.into(); + if slug.is_empty() { + return Err(Error::InvalidInput("project slug must not be empty".into())); + } + let slug_encoded = segment(&slug)?; + let url = url::Url::parse(&base).map_err(|e| Error::InvalidInput(e.to_string()))?; + if !matches!(url.scheme(), "https" | "http") + || url.host_str().is_none() + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return Err(Error::InvalidInput("invalid auth base URL".into())); + } + let http = Http::new(base.clone(), api_key, timeout)?; + Ok(Self { + http, + base, + slug, + slug_encoded, + state: Arc::new(Mutex::new(State::default())), + epoch: Arc::new(AtomicU64::new(0)), + }) + } + pub(crate) fn path(&self, p: &str) -> String { + format!("/api/v1/auth/{}{}", self.slug_encoded, p) + } + async fn post(&self, p: &str, body: Value) -> Result { + self.http + .post(&self.path(p), body) + .await + .map_err(auth_error) + } + async fn bearer(&self) -> Result { + let token = self + .state + .lock() + .await + .session + .as_ref() + .map(|s| s.access_token.clone()) + .ok_or_else(|| Error::InvalidInput("Not signed in — access token missing".into()))?; + self.http.with_token(Some(token)) + } + async fn get_auth(&self, p: &str) -> Result { + self.bearer() + .await? + .get(&self.path(p), &[]) + .await + .map_err(auth_error) + } + async fn post_auth(&self, p: &str, body: Value) -> Result { + self.bearer() + .await? + .post(&self.path(p), body) + .await + .map_err(auth_error) + } + async fn delete_auth(&self, p: &str) -> Result<()> { + let _: Value = self + .bearer() + .await? + .delete(&self.path(p), &[]) + .await + .map_err(auth_error)?; + Ok(()) + } + async fn persist_login(&self, v: &Value) -> Result<()> { + let session: Session = + serde_json::from_value(v.clone()).map_err(|e| Error::InvalidResponse(e.to_string()))?; + self.set_session(session).await; + Ok(()) + } + pub async fn session(&self) -> Option { + self.state.lock().await.session.clone() + } + pub async fn set_session(&self, session: Session) { + let mut s = self.state.lock().await; + s.session = Some(session); + s.last_refresh = None; + self.epoch.fetch_add(1, Ordering::SeqCst); + } + pub async fn clear_session(&self) { + let mut s = self.state.lock().await; + s.session = None; + s.last_refresh = None; + self.epoch.fetch_add(1, Ordering::SeqCst); + } + async fn clear_if_epoch(&self, epoch: u64) { + let mut s = self.state.lock().await; + if self.epoch.load(Ordering::SeqCst) == epoch { + s.session = None; + s.last_refresh = None; + self.epoch.fetch_add(1, Ordering::SeqCst); + } + } + pub async fn sign_up(&self, input: Value) -> Result { + let v = self.post("/signup", input).await?; + if v.get("accessToken").and_then(Value::as_str).is_some() + && v.get("refreshToken").and_then(Value::as_str).is_some() + { + self.persist_login(&v).await?; + } + Ok(v) + } + pub async fn sign_up_typed(&self, input: &SignupRequest) -> Result { + Ok(serde_json::from_value( + self.sign_up(serde_json::to_value(input)?).await?, + )?) + } + pub async fn sign_in(&self, input: Value) -> Result { + let v = self.post("/login", input).await?; + if v.get("mfaRequired") == Some(&Value::Bool(true)) { + Ok(LoginOutcome::Mfa(v)) + } else { + self.persist_login(&v).await?; + Ok(LoginOutcome::Tokens(v)) + } + } + /// Typed login preserving additional server fields in the response. + pub async fn sign_in_typed(&self, input: &SignInRequest) -> Result { + match self.sign_in(serde_json::to_value(input)?).await? { + LoginOutcome::Tokens(v) => Ok(TypedLoginOutcome::Tokens(Box::new( + serde_json::from_value(v)?, + ))), + LoginOutcome::Mfa(v) => Ok(TypedLoginOutcome::Mfa(serde_json::from_value(v)?)), + } + } + pub async fn verify_mfa_typed(&self, input: &MfaVerifyRequest) -> Result { + Ok(serde_json::from_value( + self.verify_mfa(serde_json::to_value(input)?).await?, + )?) + } + pub async fn begin_passkey_registration_typed(&self) -> Result { + Ok(serde_json::from_value( + self.begin_passkey_registration().await?, + )?) + } + pub async fn begin_passkey_authentication_typed( + &self, + email: Option<&str>, + ) -> Result { + Ok(serde_json::from_value( + self.begin_passkey_authentication(email).await?, + )?) + } + pub async fn verify_mfa(&self, input: Value) -> Result { + let v = self.post("/login/mfa/verify", input).await?; + self.persist_login(&v).await?; + Ok(v) + } + /// Concurrent callers that observe the same session share one rotation. + /// Once a rotation is sent, cancellation, malformed responses, and failures + /// discard the old session: its refresh token may have already been consumed. + pub async fn refresh(&self) -> Result { + let observed = self.epoch.load(Ordering::SeqCst); + let mut state = self.state.lock().await; + if self.epoch.load(Ordering::SeqCst) != observed { + return state + .last_refresh + .as_ref() + .filter(|(e, _)| *e == observed) + .map(|(_, v)| v.clone()) + .ok_or_else(|| Error::InvalidInput("session changed during refresh".into())); + } + // Remove before the await, so cancellation cannot permit a blind replay. + let mut session = state.session.take().ok_or_else(|| { + Error::InvalidInput("Not signed in or refresh outcome unknown".into()) + })?; + state.last_refresh = None; + let result = self + .post("/refresh", json!({"refreshToken": session.refresh_token})) + .await; + let result = result.and_then(|v| { + let access = v + .get("accessToken") + .and_then(Value::as_str) + .ok_or_else(|| Error::InvalidResponse("missing accessToken".into()))?; + let refresh = v + .get("refreshToken") + .and_then(Value::as_str) + .ok_or_else(|| Error::InvalidResponse("missing refreshToken".into()))?; + session.access_token = access.to_owned(); + session.refresh_token = refresh.to_owned(); + Ok(v) + }); + self.epoch.fetch_add(1, Ordering::SeqCst); + match result { + Ok(v) => { + state.session = Some(session); + state.last_refresh = Some((observed, v.clone())); + Ok(v) + } + Err(e) => Err(e), + } + } + pub async fn refresh_typed(&self) -> Result { + Ok(serde_json::from_value(self.refresh().await?)?) + } + pub async fn sign_out(&self) { + let old = { + let mut state = self.state.lock().await; + let old = state.session.take(); + state.last_refresh = None; + self.epoch.fetch_add(1, Ordering::SeqCst); + old + }; + if let Some(s) = old { + let _ = self + .post("/logout", json!({"refreshToken": s.refresh_token})) + .await; + } + } + pub async fn send_password_reset(&self, email: &str) -> Result { + self.post("/password-reset/request", json!({"email":email})) + .await + } + pub async fn confirm_password_reset(&self, input: Value) -> Result { + self.post("/password-reset/confirm", input).await + } + pub async fn confirm_password_reset_typed( + &self, + input: &PasswordResetConfirmRequest, + ) -> Result { + Ok(serde_json::from_value( + self.confirm_password_reset(serde_json::to_value(input)?) + .await?, + )?) + } + pub async fn send_magic_link_typed(&self, input: &MagicLinkRequest) -> Result { + self.send_magic_link(serde_json::to_value(input)?).await + } + pub async fn accept_invitation_typed( + &self, + input: &AcceptInvitationRequest, + ) -> Result { + Ok(serde_json::from_value( + self.accept_invitation(serde_json::to_value(input)?).await?, + )?) + } + pub async fn change_password_typed(&self, input: &ChangePasswordRequest) -> Result { + self.change_password(serde_json::to_value(input)?).await + } + pub async fn request_email_change_typed(&self, input: &ChangeEmailRequest) -> Result { + self.request_email_change(serde_json::to_value(input)?) + .await + } + pub async fn verify_email(&self, token: &str) -> Result { + let response = self.post("/verify-email", json!({"token":token})).await?; + if let Some(user) = response.get("user") { + let mut state = self.state.lock().await; + if let Some(session) = state.session.as_mut() { + if user.get("id").and_then(Value::as_str).is_some() + && session.user.get("id") == user.get("id") + { + session.user = user.clone(); + } + } + } + Ok(response) + } + pub async fn send_magic_link(&self, input: Value) -> Result { + self.post("/magic-link/request", input).await + } + pub async fn consume_magic_link(&self, token: &str) -> Result { + let v = self + .post("/magic-link/consume", json!({"token":token})) + .await?; + self.persist_login(&v).await?; + Ok(v) + } + pub async fn accept_invitation(&self, input: Value) -> Result { + let v = self.post("/invitation/accept", input).await?; + self.persist_login(&v).await?; + Ok(v) + } + /// The server's social URL has no state or PKCE parameters in the reference API. + /// Callers must secure the redirect at the application level. + pub fn social_authorize_url( + &self, + provider: &str, + redirect_uri: Option<&str>, + remember_me: bool, + ) -> Result { + if ![ + "google", + "github", + "facebook", + "microsoft", + "twitter", + "apple", + ] + .contains(&provider) + { + return Err(Error::InvalidInput("unsupported social provider".into())); + } + let mut url = url::Url::parse(&format!( + "{}{}/social/{}/authorize", + self.base, + self.path(""), + provider + )) + .map_err(|e| Error::InvalidInput(e.to_string()))?; + { + let mut q = url.query_pairs_mut(); + if let Some(uri) = redirect_uri { + q.append_pair("redirectUri", uri); + } + if remember_me { + q.append_pair("rememberMe", "1"); + } + } + if url.query() == Some("") { + url.set_query(None); + } + Ok(url.to_string()) + } + /// Consume a fragment supplied by the application after it has validated + /// the callback context. This API does not open a browser or validate OAuth + /// state; the reference social endpoint does not provide a state parameter. + /// Missing tokens return None; HTTP and malformed-response errors are returned. + pub async fn consume_redirect_fragment(&self, fragment: &str) -> Result> { + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let mut access = None; + let mut refresh = None; + for (key, value) in url::form_urlencoded::parse(fragment.as_bytes()) { + let target = match key.as_ref() { + "access_token" => &mut access, + "refresh_token" => &mut refresh, + _ => continue, + }; + if target.replace(value.into_owned()).is_some() { + return Err(Error::InvalidInput( + "duplicate token in redirect fragment".into(), + )); + } + } + let (access_token, refresh_token) = match (access, refresh) { + (Some(a), Some(r)) if !a.is_empty() && !r.is_empty() => (a, r), + _ => return Ok(None), + }; + let observed = self.epoch.load(Ordering::SeqCst); + let user: Value = self + .http + .with_token(Some(access_token.clone()))? + .get(&self.path("/me"), &[]) + .await + .map_err(auth_error)?; + if !user.get("id").is_some_and(Value::is_string) { + return Err(Error::InvalidResponse("invalid user response".into())); + } + let mut state = self.state.lock().await; + if self.epoch.load(Ordering::SeqCst) != observed { + return Err(Error::InvalidInput( + "session changed during redirect exchange".into(), + )); + } + state.session = Some(Session { + access_token, + refresh_token, + user: user.clone(), + }); + state.last_refresh = None; + self.epoch.fetch_add(1, Ordering::SeqCst); + Ok(Some(user)) + } + pub async fn current_user(&self) -> Result { + let token = self + .state + .lock() + .await + .session + .as_ref() + .map(|s| s.access_token.clone()) + .ok_or_else(|| Error::InvalidInput("Not signed in — access token missing".into()))?; + let v: Value = self + .http + .with_token(Some(token.clone()))? + .get(&self.path("/me"), &[]) + .await + .map_err(auth_error)?; + let mut guard = self.state.lock().await; + if let Some(s) = guard.session.as_mut() { + if s.access_token == token { + s.user = v.clone(); + } + } + Ok(v) + } + pub async fn current_user_typed(&self) -> Result { + Ok(serde_json::from_value(self.current_user().await?)?) + } + pub async fn list_sessions_typed(&self) -> Result> { + Ok(serde_json::from_value(self.list_sessions().await?)?) + } + pub async fn activity_typed(&self) -> Result> { + Ok(serde_json::from_value(self.activity().await?)?) + } + pub async fn mfa_status_typed(&self) -> Result { + Ok(serde_json::from_value(self.mfa_status().await?)?) + } + pub async fn enroll_totp_typed(&self) -> Result { + Ok(serde_json::from_value(self.enroll_totp().await?)?) + } + pub async fn verify_totp_enrollment_typed( + &self, + code: &str, + ) -> Result { + Ok(serde_json::from_value( + self.verify_totp_enrollment(code).await?, + )?) + } + pub async fn list_passkeys_typed(&self) -> Result> { + Ok(serde_json::from_value(self.list_passkeys().await?)?) + } + pub async fn list_sessions(&self) -> Result { + self.get_auth("/me/sessions").await + } + pub async fn revoke_session(&self, id: &str) -> Result<()> { + self.delete_auth(&format!("/me/sessions/{}", segment(id)?)) + .await + } + pub async fn change_password(&self, input: Value) -> Result { + let (token, epoch) = { + let state = self.state.lock().await; + let token = state + .session + .as_ref() + .ok_or_else(|| Error::InvalidInput("Not signed in".into()))? + .access_token + .clone(); + (token, self.epoch.load(Ordering::SeqCst)) + }; + let v = self + .http + .with_token(Some(token))? + .post(&self.path("/me/password"), input) + .await + .map_err(auth_error)?; + self.clear_if_epoch(epoch).await; + Ok(v) + } + pub async fn request_email_change(&self, input: Value) -> Result { + self.post_auth("/me/email/change-request", input).await + } + pub async fn confirm_email_change(&self, token: &str) -> Result { + let epoch = self.epoch.load(Ordering::SeqCst); + let v = self + .post("/me/email/change-confirm", json!({"token": token})) + .await?; + self.clear_if_epoch(epoch).await; + Ok(v) + } + pub async fn request_account_deletion(&self, password: &str) -> Result { + self.post_auth( + "/me/account/delete-request", + json!({"currentPassword":password}), + ) + .await + } + pub async fn confirm_account_deletion(&self, token: &str) -> Result { + let epoch = self.epoch.load(Ordering::SeqCst); + let v = self + .post("/me/account/delete-confirm", json!({"token":token})) + .await?; + self.clear_if_epoch(epoch).await; + Ok(v) + } + pub async fn activity(&self) -> Result { + self.get_auth("/me/activity").await + } + pub async fn mfa_status(&self) -> Result { + self.get_auth("/me/mfa").await + } + pub async fn enroll_totp(&self) -> Result { + self.post_auth("/me/mfa/totp/enroll", Value::Null).await + } + pub async fn verify_totp_enrollment(&self, code: &str) -> Result { + self.post_auth("/me/mfa/totp/verify-enrollment", json!({"code":code})) + .await + } + pub async fn disable_totp(&self, password: &str) -> Result { + self.post_auth("/me/mfa/totp/disable", json!({"currentPassword":password})) + .await + } + pub async fn list_passkeys(&self) -> Result { + self.get_auth("/me/passkey").await + } + pub async fn delete_passkey(&self, id: &str) -> Result<()> { + self.delete_auth(&format!("/me/passkey/{}", segment(id)?)) + .await + } + /// Returns WebAuthn options and challengeToken for the caller's native authenticator. + pub async fn begin_passkey_registration(&self) -> Result { + self.post_auth("/me/passkey/register/begin", Value::Null) + .await + } + pub async fn complete_passkey_registration( + &self, + challenge_token: &str, + response: Value, + device_name: Option<&str>, + ) -> Result { + let mut body = json!({"challengeToken":challenge_token,"response":response}); + if let Some(name) = device_name { + body["deviceName"] = json!(name); + } + self.post_auth("/me/passkey/register/complete", body).await + } + pub async fn begin_passkey_authentication(&self, email: Option<&str>) -> Result { + let mut body = serde_json::Map::new(); + if let Some(e) = email { + body.insert("email".into(), json!(e)); + } + self.post("/passkey/authenticate/begin", Value::Object(body)) + .await + } + pub async fn complete_passkey_authentication( + &self, + challenge_token: &str, + response: Value, + remember_me: Option, + ) -> Result { + let mut body = json!({"challengeToken":challenge_token,"response":response}); + if let Some(r) = remember_me { + body["rememberMe"] = json!(r); + } + let v = self.post("/passkey/authenticate/complete", body).await?; + self.persist_login(&v).await?; + Ok(v) + } +} diff --git a/rust/src/auth/mod.rs b/rust/src/auth/mod.rs new file mode 100644 index 0000000..3325227 --- /dev/null +++ b/rust/src/auth/mod.rs @@ -0,0 +1,8 @@ +//! Portable identity authentication. Applications own secure session storage, +//! navigation, callbacks and WebAuthn platform interactions. +mod admin; +mod client; +mod types; +pub use admin::SentroyAuthAdmin; +pub use client::SentroyAuth; +pub use types::*; diff --git a/rust/src/auth/types.rs b/rust/src/auth/types.rs new file mode 100644 index 0000000..3cbee92 --- /dev/null +++ b/rust/src/auth/types.rs @@ -0,0 +1,325 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A session is sensitive: persist it only in secure application-owned storage. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub access_token: String, + pub refresh_token: String, + pub user: Value, +} +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session") + .field("access_token", &"[REDACTED]") + .field("refresh_token", &"[REDACTED]") + .field("user", &self.user) + .finish() + } +} + +/// Login may require an MFA challenge before a session can be established. +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "lowercase")] +pub enum LoginOutcome { + Tokens(Value), + Mfa(Value), +} +impl std::fmt::Debug for LoginOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tokens(_) => f.write_str("Tokens([REDACTED])"), + Self::Mfa(_) => f.write_str("Mfa([REDACTED])"), + } + } +} + +/// User profile returned by the identity service. Unknown fields are preserved. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthUser { + pub id: String, + pub auth_project_id: String, + pub email: String, + pub email_verified: bool, + pub display_name: Option, + pub image: Option, + #[serde(default)] + pub metadata: Value, + pub last_login_at: Option, + pub created_at: String, + pub updated_at: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginResponse { + pub user: AuthUser, + pub access_token: String, + pub refresh_token: String, + pub expires_in: u64, + pub token_type: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +impl std::fmt::Debug for LoginResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoginResponse") + .field("user", &self.user) + .field("tokens", &"[REDACTED]") + .field("expires_in", &self.expires_in) + .finish() + } +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MfaChallenge { + pub mfa_required: bool, + pub mfa_token: String, + pub factor_type: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +impl std::fmt::Debug for MfaChallenge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MfaChallenge") + .field("mfa_token", &"[REDACTED]") + .field("factor_type", &self.factor_type) + .finish() + } +} +#[derive(Debug, Clone)] +pub enum TypedLoginOutcome { + Tokens(Box), + Mfa(MfaChallenge), +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignInRequest { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub remember_me: Option, +} +impl std::fmt::Debug for SignInRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignInRequest") + .field("email", &self.email) + .field("password", &"[REDACTED]") + .field("remember_me", &self.remember_me) + .finish() + } +} +/// Passkey options and challenge for a platform authenticator. The SDK does not execute WebAuthn. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyBegin { + pub options: Value, + pub challenge_token: String, +} +impl std::fmt::Debug for PasskeyBegin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PasskeyBegin") + .field("options", &self.options) + .field("challenge_token", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSummary { + pub id: String, + pub refresh_token_prefix: String, + pub user_agent: Option, + pub ip: Option, + pub expires_at: String, + pub created_at: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityEntry { + pub id: String, + pub action: String, + pub ip_address: Option, + pub created_at: String, + pub details: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MfaStatus { + pub enrolled: bool, + pub factor_type: Option, + pub verified_at: Option, + pub recovery_codes_remaining: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MfaEnrollment { + pub secret: String, + pub otpauth_uri: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +impl std::fmt::Debug for MfaEnrollment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MfaEnrollment([REDACTED])") + } +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MfaEnrollmentVerification { + pub enrolled: bool, + pub recovery_codes: Vec, + #[serde(flatten)] + pub extra: serde_json::Map, +} +impl std::fmt::Debug for MfaEnrollmentVerification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MfaEnrollmentVerification") + .field("enrolled", &self.enrolled) + .field("recovery_codes", &"[REDACTED]") + .finish() + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeySummary { + pub id: String, + pub credential_id_prefix: String, + pub device_name: Option, + pub transports: Vec, + pub last_used_at: Option, + pub created_at: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Tokens returned by a successful rotation. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthTokensResponse { + pub access_token: String, + pub refresh_token: String, + pub expires_in: u64, + pub token_type: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +impl std::fmt::Debug for AuthTokensResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthTokensResponse") + .field("tokens", &"[REDACTED]") + .field("expires_in", &self.expires_in) + .finish() + } +} +/// Signup may require email verification, in which case tokens are absent. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignupResponse { + pub user: AuthUser, + pub access_token: Option, + pub refresh_token: Option, + pub expires_in: Option, + pub token_type: Option, + pub email_verification_required: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +impl std::fmt::Debug for SignupResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignupResponse") + .field("user", &self.user) + .field("tokens", &"[REDACTED]") + .field( + "email_verification_required", + &self.email_verification_required, + ) + .finish() + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserResponse { + pub user: AuthUser, + #[serde(flatten)] + pub extra: serde_json::Map, +} +macro_rules! secret_debug { + ($($name:ident),+ $(,)?) => {$( + impl std::fmt::Debug for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(concat!(stringify!($name), "([REDACTED])")) + } + } + )+}; +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignupRequest { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MfaVerifyRequest { + pub mfa_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_code: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordResetConfirmRequest { + pub token: String, + pub new_password: String, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MagicLinkRequest { + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub redirect_uri: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcceptInvitationRequest { + pub token: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeEmailRequest { + pub new_email: String, + pub current_password: String, +} +secret_debug!( + SignupRequest, + MfaVerifyRequest, + PasswordResetConfirmRequest, + MagicLinkRequest, + AcceptInvitationRequest, + ChangePasswordRequest, + ChangeEmailRequest +); diff --git a/rust/src/error.rs b/rust/src/error.rs new file mode 100644 index 0000000..a8055e7 --- /dev/null +++ b/rust/src/error.rs @@ -0,0 +1,106 @@ +use serde_json::Value; +use std::fmt; + +/// An SDK failure. Compose errors retain the UUID needed for explicit recovery. +pub enum Error { + InvalidInput(String), + InvalidResponse(String), + Http { + status: u16, + message: String, + body: Value, + }, + Transport(String), + Json(serde_json::Error), + Io(std::io::Error), + Auth { + code: String, + description: String, + status: Option, + }, + Mcp(String), + ComposeRequest { + request_id: String, + source: Box, + }, +} + +impl Error { + pub fn status_code(&self) -> Option { + match self { + Self::Http { status, .. } => Some(*status), + Self::Auth { status, .. } => *status, + Self::ComposeRequest { source, .. } => source.status_code(), + _ => None, + } + } + pub fn body(&self) -> Option<&Value> { + match self { + Self::Http { body, .. } => Some(body), + Self::ComposeRequest { source, .. } => source.body(), + _ => None, + } + } + pub fn compose_request_id(&self) -> Option<&str> { + match self { + Self::ComposeRequest { request_id, .. } => Some(request_id), + _ => None, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(s) => write!(f, "Invalid input: {s}"), + Self::InvalidResponse(s) => write!(f, "Invalid response: {s}"), + Self::Http { + status, message, .. + } => write!(f, "Sentroy HTTP {status}: {message}"), + Self::Transport(s) => write!(f, "HTTP transport failed: {s}"), + Self::Json(e) => write!(f, "JSON conversion failed: {e}"), + Self::Io(e) => write!(f, "I/O failed: {e}"), + Self::Auth { + code, description, .. + } => write!(f, "Auth {code}: {description}"), + Self::Mcp(s) => write!(f, "MCP failed: {s}"), + Self::ComposeRequest { request_id, source } => write!( + f, + "Compose request {request_id} requires explicit outcome recovery: {source}" + ), + } + } +} + +// Deliberately do not include response bodies in Debug output. +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Json(e) => Some(e), + Self::Io(e) => Some(e), + Self::ComposeRequest { source, .. } => Some(source.as_ref()), + _ => None, + } + } +} +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Self::Json(e) + } +} +impl From for Error { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Self::Transport(e.without_url().to_string()) + } +} +pub type Result = std::result::Result; diff --git a/rust/src/http.rs b/rust/src/http.rs new file mode 100644 index 0000000..53c5869 --- /dev/null +++ b/rust/src/http.rs @@ -0,0 +1,357 @@ +use crate::{Error, Result}; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use reqwest::{header, Method, RequestBuilder, Response}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::Value; +use std::{fmt, time::Duration}; +use url::Url; + +const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~') + .remove(b'!') + .remove(b'\'') + .remove(b'(') + .remove(b')') + .remove(b'*'); +const JSON_LIMIT: usize = 16 * 1024 * 1024; +pub(crate) type Query = Vec<(String, String)>; + +/// Encode exactly one path segment. Dot segments are rejected before URL parsing. +pub(crate) fn segment(value: &str) -> Result { + if value.is_empty() || value == "." || value == ".." || value.chars().any(char::is_control) { + return Err(Error::InvalidInput( + "empty, control or dot path segment".into(), + )); + } + Ok(utf8_percent_encode(value, SEGMENT).to_string()) +} +pub(crate) fn query(value: &T) -> Result { + let value = serde_json::to_value(value)?; + if value.is_null() { + return Ok(Vec::new()); + } + let fields = value + .as_object() + .ok_or_else(|| Error::InvalidInput("query must be an object".into()))?; + fields + .iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| { + let text = match v { + Value::String(s) => s.clone(), + Value::Bool(_) | Value::Number(_) => v.to_string(), + Value::Array(items) => items + .iter() + .map(|i| match i { + Value::String(s) => Ok(s.clone()), + Value::Bool(_) | Value::Number(_) => Ok(i.to_string()), + _ => Err(Error::InvalidInput( + "query array contains a non-scalar".into(), + )), + }) + .collect::>>()? + .join(","), + _ => return Err(Error::InvalidInput("query contains a non-scalar".into())), + }; + Ok((k.clone(), text)) + }) + .collect() +} + +#[derive(Clone)] +pub(crate) struct Http { + base: String, + token: Option, + client: reqwest::Client, +} +impl fmt::Debug for Http { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Http") + .field("base", &self.base) + .field("authenticated", &self.token.is_some()) + .finish() + } +} +impl Http { + pub(crate) fn new( + base: impl Into, + token: Option, + timeout: Duration, + ) -> Result { + let base = base.into().trim_end_matches('/').to_string(); + let url = Url::parse(&base).map_err(|_| Error::InvalidInput("invalid base URL".into()))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || timeout.is_zero() + { + return Err(Error::InvalidInput("base URL must be HTTP(S), without credentials, query or fragment; timeout must be positive".into())); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .timeout(timeout) + .build()?; + Self { + base, + token: None, + client, + } + .with_token(token) + } + pub(crate) fn with_token(&self, token: Option) -> Result { + let token = token + .map(|t| { + if t.is_empty() { + return Err(Error::InvalidInput("empty access token".into())); + } + let mut v = header::HeaderValue::from_str(&format!("Bearer {t}")) + .map_err(|_| Error::InvalidInput("invalid access token header".into()))?; + v.set_sensitive(true); + Ok(v) + }) + .transpose()?; + Ok(Self { + base: self.base.clone(), + token, + client: self.client.clone(), + }) + } + fn request( + &self, + method: Method, + path: &str, + query: &[(String, String)], + ) -> Result { + if !path.starts_with('/') + || path.starts_with("//") + || path.contains(['?', '#']) + || path.chars().any(char::is_control) + { + return Err(Error::InvalidInput("invalid relative API path".into())); + } + let url = Url::parse(&format!("{}{path}", self.base)) + .map_err(|_| Error::InvalidInput("invalid API URL".into()))?; + let mut request = self + .client + .request(method, url) + .query(query) + .header(header::ACCEPT, "application/json"); + if let Some(token) = &self.token { + request = request.header(header::AUTHORIZATION, token.clone()); + } + Ok(request) + } + async fn read_limited(mut response: Response, limit: usize) -> Result<(u16, Vec)> { + let status = response.status().as_u16(); + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err(Error::InvalidResponse("response exceeds byte limit".into())); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if chunk.len() > limit.saturating_sub(bytes.len()) { + return Err(Error::InvalidResponse("response exceeds byte limit".into())); + } + bytes.extend_from_slice(&chunk); + } + Ok((status, bytes)) + } + async fn decode(response: Response, once: bool) -> Result { + if once + && (response.status().is_redirection() + || !response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.to_ascii_lowercase().starts_with("application/json"))) + { + return Err(Error::InvalidResponse("invalid compose receipt".into())); + } + let (status, bytes) = + Self::read_limited(response, if once { 4096 } else { JSON_LIMIT }).await?; + let parsed = if bytes.is_empty() { + Ok(Value::Null) + } else { + serde_json::from_slice(&bytes) + }; + if !(200..300).contains(&status) { + let body = parsed.unwrap_or(Value::Null); + let message = body + .get("error") + .and_then(Value::as_str) + .or_else(|| body.get("error_description").and_then(Value::as_str)) + .unwrap_or("request failed") + .to_string(); + return Err(Error::Http { + status, + message, + body, + }); + } + let mut value = parsed?; + let payload = if value.as_object().is_some_and(|v| v.contains_key("data")) { + value.get_mut("data").expect("checked data").take() + } else { + value + }; + Ok(serde_json::from_value(payload)?) + } + pub(crate) async fn get( + &self, + path: &str, + query: &[(String, String)], + ) -> Result { + Self::decode(self.request(Method::GET, path, query)?.send().await?, false).await + } + pub(crate) async fn post(&self, path: &str, body: Value) -> Result { + let request = self.request(Method::POST, path, &[])?; + let request = if body.is_null() { + request + } else { + request.json(&body) + }; + Self::decode(request.send().await?, false).await + } + pub(crate) async fn patch(&self, path: &str, body: Value) -> Result { + Self::decode( + self.request(Method::PATCH, path, &[])? + .json(&body) + .send() + .await?, + false, + ) + .await + } + pub(crate) async fn delete( + &self, + path: &str, + query: &[(String, String)], + ) -> Result { + Self::decode( + self.request(Method::DELETE, path, query)?.send().await?, + false, + ) + .await + } + pub(crate) async fn delete_body( + &self, + path: &str, + body: Value, + ) -> Result { + Self::decode( + self.request(Method::DELETE, path, &[])? + .json(&body) + .send() + .await?, + false, + ) + .await + } + pub(crate) async fn post_once( + &self, + path: &str, + body: Value, + ) -> Result { + if !matches!(path, "/send-bcc" | "/inbox/drafts-bcc-v1") { + return Err(Error::InvalidInput("invalid compose path".into())); + } + Self::decode( + self.request(Method::POST, path, &[])? + .json(&body) + .send() + .await?, + true, + ) + .await + } + pub(crate) async fn post_form( + &self, + path: &str, + body: reqwest::multipart::Form, + ) -> Result { + Self::decode( + self.request(Method::POST, path, &[])? + .multipart(body) + .send() + .await?, + false, + ) + .await + } + pub(crate) async fn raw_get(&self, path: &str, query: &[(String, String)]) -> Result { + let response = self.request(Method::GET, path, query)?.send().await?; + if !response.status().is_success() { + return match Self::decode::(response, false).await { + Err(e) => Err(e), + Ok(_) => Err(Error::InvalidResponse("unsuccessful raw response".into())), + }; + } + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn encoded_segments_cannot_escape_the_company_route() { + assert_eq!( + segment("tenant/name?x=#").unwrap(), + "tenant%2Fname%3Fx%3D%23" + ); + assert!(segment("..").is_err()); + assert!(segment(".").is_err()); + assert!(segment("bad\r\nheader").is_err()); + } + #[test] + fn queries_preserve_false_zero_and_explicit_lists() { + let q = query(&json!({"none":null,"enabled":false,"page":0,"tags":["a","b"]})).unwrap(); + assert!(!q.iter().any(|(k, _)| k == "none")); + assert!(q.contains(&("enabled".into(), "false".into()))); + assert!(q.contains(&("page".into(), "0".into()))); + assert!(q.contains(&("tags".into(), "a,b".into()))); + assert!(query(&json!({"nested":{"bad":true}})).is_err()); + } + #[test] + fn credentials_are_redacted_and_header_injection_is_refused() { + let h = Http::new( + "https://sentroy.com", + Some("sdk-test-secret".into()), + Duration::from_secs(1), + ) + .unwrap(); + assert!(!format!("{h:?}").contains("sdk-test-secret")); + let r = h + .request(Method::GET, "/test", &[]) + .unwrap() + .build() + .unwrap(); + assert_eq!(r.headers()[header::AUTHORIZATION], "Bearer sdk-test-secret"); + assert!(h.with_token(Some("bad\r\ninjected: yes".into())).is_err()); + let e = Error::Http { + status: 403, + message: "forbidden".into(), + body: json!({"secret":"sdk-test-secret"}), + }; + assert!(!format!("{e:?}").contains("sdk-test-secret")); + } + #[test] + fn origin_validation_rejects_embedded_credentials_and_zero_timeout() { + for u in [ + "file:///tmp/test", + "https://user:password@sentroy.com", + "https://sentroy.com/?token=x", + "https://sentroy.com/#secret", + ] { + assert!(Http::new(u, None, Duration::from_secs(1)).is_err()); + } + assert!(Http::new("https://sentroy.com", None, Duration::ZERO).is_err()); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..b6ed993 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,117 @@ +#![doc = include_str!("../README.md")] +pub mod auth; +mod error; +pub(crate) mod http; +pub mod mcp; +pub mod resources; +pub mod thumbnails; +pub mod types; +pub mod vault; + +pub use error::{Error, Result}; +use http::{segment, Http}; +pub use resources::{mail::*, storage::*, whatsapp::*}; +use std::time::Duration; + +/// One company-scoped client. Clones share HTTP connection pools. +#[derive(Clone, Debug)] +pub struct Sentroy { + pub domains: Domains, + pub mailboxes: Mailboxes, + pub templates: Templates, + pub inbox: Inbox, + pub send: Send, + pub audience: Audience, + pub suppressions: Suppressions, + pub webhooks: Webhooks, + pub logs: Logs, + pub buckets: Buckets, + pub media: MediaResource, + pub storage: Storage, + pub whatsapp: WhatsApp, +} +impl Sentroy { + pub fn new( + base_url: impl Into, + company_slug: impl Into, + access_token: impl Into, + ) -> Result { + Self::builder(company_slug) + .base_url(base_url) + .access_token(access_token) + .build() + } + pub fn builder(company_slug: impl Into) -> SentroyBuilder { + SentroyBuilder { + base_url: "https://sentroy.com".into(), + company_slug: company_slug.into(), + token: None, + timeout: Duration::from_secs(30), + } + } +} +/// Client configuration. Tokens are excluded from Debug output. +pub struct SentroyBuilder { + base_url: String, + company_slug: String, + token: Option, + timeout: Duration, +} +impl std::fmt::Debug for SentroyBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SentroyBuilder") + .field("base_url", &"[configured]") + .field("company_slug", &self.company_slug) + .field("authenticated", &self.token.is_some()) + .field("timeout", &self.timeout) + .finish() + } +} +impl SentroyBuilder { + pub fn base_url(mut self, value: impl Into) -> Self { + self.base_url = value.into(); + self + } + pub fn access_token(mut self, value: impl Into) -> Self { + self.token = Some(value.into()); + self + } + pub fn timeout(mut self, value: Duration) -> Self { + self.timeout = value; + self + } + pub fn build(self) -> Result { + let root = self.base_url.trim_end_matches('/'); + let slug = segment(&self.company_slug)?; + let mail = Http::new( + format!("{root}/api/mail/companies/{slug}"), + self.token.clone(), + self.timeout, + )?; + let storage = Http::new( + format!("{root}/api/storage/companies/{slug}"), + self.token.clone(), + self.timeout, + )?; + let whatsapp = Http::new( + format!("{root}/api/whatsapp/companies/{slug}"), + self.token, + self.timeout, + )?; + Ok(Sentroy { + domains: Domains::new(mail.clone()), + mailboxes: Mailboxes::new(mail.clone()), + templates: Templates::new(mail.clone()), + inbox: Inbox::new(mail.clone()), + send: Send::new(mail.clone()), + audience: Audience::new(mail.clone()), + suppressions: Suppressions::new(mail.clone()), + webhooks: Webhooks::new(mail.clone()), + logs: Logs::new(mail), + buckets: Buckets::new(storage.clone()), + media: MediaResource::new(storage.clone()), + storage: Storage::new(storage), + whatsapp: WhatsApp::new(whatsapp), + }) + } +} diff --git a/rust/src/mcp/credential_client.rs b/rust/src/mcp/credential_client.rs new file mode 100644 index 0000000..7363ca2 --- /dev/null +++ b/rust/src/mcp/credential_client.rs @@ -0,0 +1,101 @@ +//! Credential refresh adapter around the official rmcp HTTP implementation. +use super::McpTokenProvider; +use futures_util::stream::BoxStream; +use reqwest13::header::{HeaderName, HeaderValue}; +use rmcp::{ + model::ClientJsonRpcMessage, + transport::streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::Sse; +use std::{collections::HashMap, sync::Arc}; + +#[derive(Clone)] +pub(super) struct CredentialClient { + pub client: reqwest13::Client, + pub provider: McpTokenProvider, + pub resource: Arc, +} +impl CredentialClient { + async fn token(&self, uri: &str) -> Result> { + if uri != self.resource.as_ref() { + return Err(StreamableHttpError::UnexpectedServerResponse( + "invalid_mcp_target".into(), + )); + } + self.provider.resolve().await.map_err(|_| { + StreamableHttpError::UnexpectedServerResponse("mcp_token_unavailable".into()) + }) + } +} +impl StreamableHttpClient for CredentialClient { + type Error = reqwest13::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session: Option>, + _auth: Option, + headers: HashMap, + ) -> Result> { + let token = self.token(&uri).await?; + self.client + .post_message(uri, message, session, Some(token), headers) + .await + } + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session: Option>, + _auth: Option, + headers: HashMap, + max: usize, + ) -> Result> { + let token = self.token(&uri).await?; + self.client + .post_message_with_max_sse_event_size(uri, message, session, Some(token), headers, max) + .await + } + async fn delete_session( + &self, + uri: Arc, + session: Arc, + _auth: Option, + headers: HashMap, + ) -> Result<(), StreamableHttpError> { + let token = self.token(&uri).await?; + self.client + .delete_session(uri, session, Some(token), headers) + .await + } + async fn get_stream( + &self, + uri: Arc, + session: Option>, + last: Option, + _auth: Option, + headers: HashMap, + ) -> Result>, StreamableHttpError> { + let token = self.token(&uri).await?; + self.client + .get_stream(uri, session, last, Some(token), headers) + .await + } + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session: Option>, + last: Option, + _auth: Option, + headers: HashMap, + max: usize, + ) -> Result>, StreamableHttpError> { + let token = self.token(&uri).await?; + self.client + .get_stream_with_max_sse_event_size(uri, session, last, Some(token), headers, max) + .await + } +} diff --git a/rust/src/mcp/file_info.rs b/rust/src/mcp/file_info.rs new file mode 100644 index 0000000..b9a75f4 --- /dev/null +++ b/rust/src/mcp/file_info.rs @@ -0,0 +1,96 @@ +//! Local manifests for the two private upload workflows. Computing a manifest +//! never sends a request or publishes a file. +use crate::Error; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +fn invalid(code: &str) -> Error { + Error::Mcp(code.to_owned()) +} + +fn digest(bytes: &[u8]) -> String { + let hash = Sha256::digest(bytes); + hash.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContactImportFileInfo { + pub format: String, + pub bytes: usize, + pub file_hash: String, +} + +/// Compute metadata for a contact upload. The body stays local. +pub fn sentroy_mcp_contact_import_file_info( + body: &[u8], + format: &str, +) -> Result { + if !matches!(format, "csv" | "jsonl") || body.is_empty() || body.len() > 1024 * 1024 { + return Err(invalid("invalid_mcp_contact_file")); + } + Ok(ContactImportFileInfo { + format: format.to_owned(), + bytes: body.len(), + file_hash: digest(body), + }) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StorageUploadFileInfo { + pub name: String, + pub mime_type: String, + pub bytes: usize, + pub sha256: String, +} + +fn valid_storage_name(name: &str, mime_type: &str) -> bool { + let extensions: &[&str] = match mime_type { + "application/pdf" => &["pdf"], + "image/jpeg" => &["jpg", "jpeg"], + "image/png" => &["png"], + "image/webp" => &["webp"], + "text/plain" => &["txt", "log"], + "text/csv" => &["csv"], + "audio/mpeg" => &["mp3"], + "audio/wav" => &["wav"], + "audio/ogg" => &["ogg", "oga"], + "audio/mp4" => &["m4a", "mp4"], + "audio/webm" => &["webm"], + "video/mp4" => &["mp4"], + "video/webm" => &["webm"], + "video/quicktime" => &["mov"], + _ => return false, + }; + if name.is_empty() + || name.encode_utf16().count() > 200 + || name.trim() != name + || name + .chars() + .any(|c| c == '/' || c == '\\' || c.is_control()) + { + return false; + } + let Some((stem, extension)) = name.rsplit_once('.') else { + return false; + }; + !stem.is_empty() && extensions.contains(&extension.to_ascii_lowercase().as_str()) +} + +/// Compute metadata for a storage upload. The body stays local. +pub fn sentroy_mcp_storage_upload_file_info( + body: &[u8], + name: &str, + mime_type: &str, +) -> Result { + if !valid_storage_name(name, mime_type) || body.is_empty() || body.len() > 10 * 1024 * 1024 { + return Err(invalid("invalid_mcp_storage_upload")); + } + Ok(StorageUploadFileInfo { + name: name.to_owned(), + mime_type: mime_type.to_owned(), + bytes: body.len(), + sha256: digest(body), + }) +} diff --git a/rust/src/mcp/mod.rs b/rust/src/mcp/mod.rs new file mode 100644 index 0000000..86c9d7d --- /dev/null +++ b/rust/src/mcp/mod.rs @@ -0,0 +1,310 @@ +//! Native MCP client backed by the official Rust SDK. +//! +//! MCP credentials are a separate namespace from API keys. A connection never +//! acquires credentials, follows redirects, or replays requests on session expiry. + +use crate::Error; +/// Official MCP protocol types used by the exposed client. +pub use rmcp as protocol; +use rmcp::{ + model::{ClientConfig, Implementation}, + service::{RoleClient, RunningService}, + transport::{ + common::client_side_sse::NeverRetry, + streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + }, + }, + ServiceExt, +}; +use std::{future::Future, pin::Pin, sync::Arc}; +use url::Url; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum McpApp { + Core, + Mail, + Storage, + Linear, + Chaby, +} + +impl McpApp { + fn origin(self) -> &'static str { + match self { + Self::Core => "https://sentroy.com", + Self::Mail => "https://mail.sentroy.com", + Self::Storage => "https://storage.sentroy.com", + Self::Linear => "https://linear.sentroy.com", + Self::Chaby => "https://chaby.sentroy.com", + } + } +} + +#[derive(Clone, Debug)] +pub struct McpResourceOptions { + pub app: McpApp, + pub company_slug: String, + /// Origin only. HTTPS, or HTTP on a loopback host. + pub origin: Option, +} + +fn invalid(code: &str) -> Error { + Error::Mcp(code.to_owned()) +} + +fn valid_slug(slug: &str) -> bool { + let bytes = slug.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 100 + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes + .iter() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_' || *c == b'-') +} + +/// Construct the protected resource URL without making any network requests. +pub fn sentroy_mcp_resource(options: &McpResourceOptions) -> Result { + if !valid_slug(&options.company_slug) { + return Err(invalid("invalid_mcp_resource")); + } + let origin = Url::parse(options.origin.as_deref().unwrap_or(options.app.origin())) + .map_err(|_| invalid("invalid_mcp_origin"))?; + let loopback = matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if !origin.username().is_empty() + || origin.password().is_some() + || origin.fragment().is_some() + || origin.query().is_some() + || origin.path() != "/" + || !(origin.scheme() == "https" || (origin.scheme() == "http" && loopback)) + || origin.host().is_none() + { + return Err(invalid("invalid_mcp_origin")); + } + Ok(format!( + "{}/mcp/{}", + origin.origin().ascii_serialization(), + options.company_slug + )) +} + +/// URL for OAuth protected-resource metadata; this does not perform discovery. +pub fn sentroy_mcp_resource_metadata(options: &McpResourceOptions) -> Result { + let resource = + Url::parse(&sentroy_mcp_resource(options)?).map_err(|_| invalid("invalid_mcp_resource"))?; + Ok(format!( + "{}/.well-known/oauth-protected-resource{}", + resource.origin().ascii_serialization(), + resource.path() + )) +} + +fn valid_token(token: &str) -> bool { + let valid_suffix = |s: &str| { + s.len() == 43 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + }; + token.strip_prefix("mcp_").is_some_and(valid_suffix) + || token.strip_prefix("mcp_at_").is_some_and(valid_suffix) +} + +/// Connected official protocol client. Use `client.peer()` to call MCP methods. +pub struct McpConnection { + resource: String, + pub client: RunningService, + app: McpApp, + provider: McpTokenProvider, + file_client: reqwest::Client, +} + +type TokenFuture = Pin> + Send>>; +type TokenCallback = dyn Fn() -> TokenFuture + Send + Sync; + +/// An asynchronous source of MCP credentials. Provider errors are sanitized. +#[derive(Clone)] +pub struct McpTokenProvider { + callback: Arc, +} +impl McpTokenProvider { + pub fn new(callback: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Self { + callback: Arc::new(move || Box::pin(callback())), + } + } + async fn resolve(&self) -> Result { + let token = (self.callback)() + .await + .map_err(|_| invalid("mcp_token_unavailable"))?; + if !valid_token(&token) { + return Err(invalid("invalid_mcp_token")); + } + Ok(token) + } +} +impl std::fmt::Debug for McpTokenProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("McpTokenProvider([REDACTED])") + } +} +/// Client identity sent in the initialization request. +#[derive(Clone, Debug)] +pub struct McpClientInfo { + pub name: String, + pub version: String, +} + +/// Connect with a static personal or OAuth MCP access token. +pub async fn connect_sentroy_mcp( + options: &McpResourceOptions, + token: &str, +) -> Result { + let token = token.to_owned(); + let provider = McpTokenProvider::new(move || { + let token = token.clone(); + async move { Ok(token) } + }); + connect_sentroy_mcp_with_provider(options, provider, None).await +} + +/// Connect using a source resolved before every MCP and private-file request. +pub async fn connect_sentroy_mcp_with_provider( + options: &McpResourceOptions, + provider: McpTokenProvider, + info: Option, +) -> Result { + let resource = sentroy_mcp_resource(options)?; + provider.resolve().await?; + let file_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build() + .map_err(|_| invalid("mcp_connection_failed"))?; + let client = reqwest13::Client::builder() + .redirect(reqwest13::redirect::Policy::none()) + .retry(reqwest13::retry::never()) + .build() + .map_err(|_| invalid("mcp_connection_failed"))?; + let mut config = StreamableHttpClientTransportConfig::with_uri(resource.clone()) + .reinit_on_expired_session(false) + .max_sse_event_size(1024 * 1024); + config.retry_config = Arc::new(NeverRetry::default()); + let transport = StreamableHttpClientTransport::with_client( + credential_client::CredentialClient { + client, + provider: provider.clone(), + resource: Arc::from(resource.as_str()), + }, + config, + ); + let info = info.unwrap_or_else(|| McpClientInfo { + name: "sentroy-client-sdk".into(), + version: env!("CARGO_PKG_VERSION").into(), + }); + let mut client_info = ClientConfig::default(); + client_info.client_info = Implementation::new(info.name, info.version); + let service = client_info + .serve(transport) + .await + .map_err(|_| invalid("mcp_connection_failed"))?; + Ok(McpConnection { + resource, + client: service, + app: options.app, + provider, + file_client, + }) +} +impl McpConnection { + /// The immutable protected resource bound when this connection was opened. + pub fn resource(&self) -> &str { + &self.resource + } + /// Close the official MCP service and its transport. + pub async fn close(&mut self) -> Result<(), Error> { + self.client + .close() + .await + .map(|_| ()) + .map_err(|_| invalid("mcp_close_failed")) + } +} +mod credential_client; +mod transfers; +pub use transfers::TransferOptions; +mod file_info; +pub use file_info::{ + sentroy_mcp_contact_import_file_info, sentroy_mcp_storage_upload_file_info, + ContactImportFileInfo, StorageUploadFileInfo, +}; + +#[cfg(test)] +mod tests { + use super::*; + fn resource() -> McpResourceOptions { + McpResourceOptions { + app: McpApp::Mail, + company_slug: "example".into(), + origin: None, + } + } + #[test] + fn resource_and_metadata_bind_slug_and_origin() { + let mut opts = resource(); + assert_eq!( + sentroy_mcp_resource(&opts).unwrap(), + "https://mail.sentroy.com/mcp/example" + ); + assert_eq!( + sentroy_mcp_resource_metadata(&opts).unwrap(), + "https://mail.sentroy.com/.well-known/oauth-protected-resource/mcp/example" + ); + opts.origin = Some("http://localhost:8080".into()); + assert_eq!( + sentroy_mcp_resource(&opts).unwrap(), + "http://localhost:8080/mcp/example" + ); + for bad in [ + "http://example.com", + "https://user:pass@example.com", + "https://example.com/a", + "https://example.com/?q=1", + "https://example.com/#f", + ] { + opts.origin = Some(bad.into()); + assert!(sentroy_mcp_resource(&opts).is_err()); + } + opts.origin = None; + for bad in ["", "A", "../x", "a/b", "with space"] { + opts.company_slug = bad.into(); + assert!(sentroy_mcp_resource(&opts).is_err()); + } + } + #[test] + fn token_namespace_and_manifests() { + assert!(valid_token(&format!("mcp_{}", "a".repeat(43)))); + assert!(valid_token(&format!("mcp_at_{}", "a".repeat(43)))); + assert!(!valid_token(&format!("stk_{}", "a".repeat(43)))); + assert!(!valid_token(&format!("mcp_{}", "a".repeat(42)))); + let contact = sentroy_mcp_contact_import_file_info(b"abc", "csv").unwrap(); + assert_eq!( + contact.file_hash, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert!(sentroy_mcp_contact_import_file_info(b"", "csv").is_err()); + assert!(sentroy_mcp_contact_import_file_info(b"abc", "txt").is_err()); + let file = + sentroy_mcp_storage_upload_file_info(b"abc", "file.PDF", "application/pdf").unwrap(); + assert_eq!(file.sha256, contact.file_hash); + assert!( + sentroy_mcp_storage_upload_file_info(b"abc", "../file.pdf", "application/pdf").is_err() + ); + assert!( + sentroy_mcp_storage_upload_file_info(b"abc", "file.txt", "application/pdf").is_err() + ); + } +} diff --git a/rust/src/mcp/transfers.rs b/rust/src/mcp/transfers.rs new file mode 100644 index 0000000..a8a42d3 --- /dev/null +++ b/rust/src/mcp/transfers.rs @@ -0,0 +1,608 @@ +//! Bound private-file transfers. References are validated before any request. +//! Upload transport failures have an unknown outcome; callers must reconcile +//! state through the appropriate MCP tool before deciding whether to retry. +use super::{McpApp, McpConnection}; +use crate::Error; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::time::Duration; +use url::Url; + +const CONTACT_CAP: usize = 1024 * 1024; +const STORAGE_CAP: usize = 10 * 1024 * 1024; +const DOWNLOAD_CAP: usize = 64 * 1024 * 1024; +fn err(s: &str) -> Error { + Error::Mcp(s.into()) +} +fn field<'a>(v: &'a Value, key: &str) -> Result<&'a str, Error> { + v.get(key) + .and_then(Value::as_str) + .ok_or_else(|| err("invalid_mcp_reference")) +} +fn size(v: &Value, key: &str, cap: usize) -> Result { + v.get(key) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n <= cap) + .ok_or_else(|| err("invalid_mcp_reference")) +} +fn hex(s: &str, len: usize) -> bool { + s.len() == len + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn slug(s: &str) -> bool { + !s.is_empty() + && s.len() <= 100 + && s.as_bytes()[0].is_ascii_alphanumeric() + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + && (s.as_bytes()[0].is_ascii_lowercase() || s.as_bytes()[0].is_ascii_digit()) +} +fn uuid(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 36 + && [8, 13, 18, 23].iter().all(|i| b[*i] == b'-') + && b.iter().enumerate().all(|(i, c)| { + [8, 13, 18, 23].contains(&i) || c.is_ascii_digit() || (b'a'..=b'f').contains(c) + }) + && (b'1'..=b'8').contains(&b[14]) + && matches!(b[19], b'8' | b'9' | b'a' | b'b') +} +fn date(s: &str, future: bool) -> bool { + let Ok(d) = DateTime::parse_from_rfc3339(s) else { + return false; + }; + d.to_utc().to_rfc3339_opts(SecondsFormat::Millis, true) == s && (!future || d > Utc::now()) +} +fn sha(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +fn flag(v: &Value, key: &str, b: bool) -> bool { + v.get(key).and_then(Value::as_bool) == Some(b) +} + +fn url_for(connection: &McpConnection, raw: &str, max: usize) -> Result { + if raw.len() > max { + return Err(err("invalid_mcp_reference")); + } + let url = Url::parse(raw).map_err(|_| err("invalid_mcp_reference"))?; + let resource = Url::parse(&connection.resource).map_err(|_| err("invalid_mcp_reference"))?; + if url.origin() != resource.origin() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(err("invalid_mcp_reference")); + } + Ok(url) +} +fn query(url: &Url, keys: &[&str]) -> Result, Error> { + let pairs: Vec<_> = url.query_pairs().collect(); + if pairs.len() != keys.len() { + return Err(err("invalid_mcp_reference")); + } + let mut out = HashMap::new(); + for (k, v) in pairs { + if !keys.contains(&k.as_ref()) || out.insert(k.into_owned(), v.into_owned()).is_some() { + return Err(err("invalid_mcp_reference")); + } + } + if !out.get("connection").is_some_and(|s| hex(s, 24)) { + return Err(err("invalid_mcp_reference")); + } + Ok(out) +} +fn resource_path(c: &McpConnection) -> Result { + Ok(Url::parse(&c.resource) + .map_err(|_| err("invalid_mcp_resource"))? + .path() + .to_owned()) +} +fn contact_ref( + c: &McpConnection, + v: &Value, + kind: &str, +) -> Result<(Url, usize, String, String), Error> { + if c.app != McpApp::Mail { + return Err(err("mcp_contact_files_not_supported")); + } + if !flag(v, "requiresOriginalBearer", true) { + return Err(err("invalid_mcp_reference")); + } + let bytes = size(v, "bytes", CONTACT_CAP)?; + let id = field( + v, + if kind == "exports" { + "exportId" + } else { + "uploadId" + }, + )?; + let hash = field(v, "fileHash")?; + let expires = field(v, "expiresAt")?; + if !uuid(id) || !hex(hash, 64) || !date(expires, true) || (kind == "imports" && bytes == 0) { + return Err(err("invalid_mcp_reference")); + } + let u = url_for( + c, + field( + v, + if kind == "exports" { + "downloadUrl" + } else { + "uploadUrl" + }, + )?, + 4000, + )?; + if u.path() != format!("{}/contacts/{kind}/{id}", resource_path(c)?) { + return Err(err("invalid_mcp_reference")); + } + let q = query( + &u, + if kind == "exports" { + &["connection", "hash"] + } else { + &["connection"] + }, + )?; + if kind == "exports" && q.get("hash").map(String::as_str) != Some(hash) { + return Err(err("invalid_mcp_reference")); + } + Ok((u, bytes, hash.to_owned(), expires.to_owned())) +} +/// Transfer timeout covers the complete request and body; defaults are 60s +/// for downloads and 15s for uploads. Maximum is 120s. +#[derive(Clone, Copy, Debug, Default)] +pub struct TransferOptions { + pub timeout_ms: Option, +} +fn timeout(o: Option, default: u64) -> Result { + let ms = o.and_then(|v| v.timeout_ms).unwrap_or(default); + if !(1..=120_000).contains(&ms) { + return Err(err("invalid_mcp_timeout")); + } + Ok(Duration::from_millis(ms)) +} +async fn bounded( + mut response: reqwest::Response, + cap: usize, + expected: Option, +) -> Result, Error> { + if let Some(length) = response.content_length() { + if length > cap as u64 || expected.is_some_and(|n| n as u64 != length) { + return Err(err("mcp_file_size_mismatch")); + } + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| err("mcp_download_failed"))? + { + if chunk.len() > cap.saturating_sub(bytes.len()) + || expected.is_some_and(|n| chunk.len() > n.saturating_sub(bytes.len())) + { + return Err(err("mcp_file_size_mismatch")); + } + bytes.extend_from_slice(&chunk); + } + if expected.is_some_and(|n| n != bytes.len()) { + return Err(err("mcp_file_size_mismatch")); + } + Ok(bytes) +} +impl McpConnection { + async fn get_file( + &self, + u: Url, + cap: usize, + expected: Option, + duration: Duration, + ) -> Result, Error> { + tokio::time::timeout(duration, async { + let token = self.provider.resolve().await?; + let r = self + .file_client + .get(u) + .bearer_auth(token) + .send() + .await + .map_err(|_| err("mcp_download_failed"))?; + if r.status() != 200 { + return Err(err("mcp_download_failed")); + } + bounded(r, cap, expected).await + }) + .await + .map_err(|_| err("mcp_download_aborted"))? + } + async fn put_file( + &self, + u: Url, + body: &[u8], + duration: Duration, + storage: bool, + expires: &str, + ) -> Result, Error> { + // Once send starts, timeout, connection loss, or invalid response cannot + // determine whether the server accepted the bytes. Never replay it. + tokio::time::timeout(duration, async { + let token = self.provider.resolve().await?; + if !date(expires, true) { + return Err(err("invalid_mcp_reference")); + } + let r = self + .file_client + .put(u) + .bearer_auth(token) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(body.to_vec()) + .send() + .await + .map_err(|_| err("mcp_upload_outcome_unknown"))?; + let status = r.status().as_u16(); + if status != 200 { + if status >= 500 { + return Err(err("mcp_upload_outcome_unknown")); + } + let mut safe = Value::Null; + if !storage && [400, 409, 413, 422].contains(&status) { + let data = bounded(r, 4096, None).await.unwrap_or_default(); + if let Ok(value) = serde_json::from_slice::(&data) { + let allowed = [ + "contact_import_columns", + "contact_import_row_limit", + "contact_import_csv_invalid", + "contact_import_csv_columns", + "contact_import_csv_escaped_email", + "contact_import_tags_invalid", + "contact_import_utf8_invalid", + "contact_import_nul", + "contact_import_json_invalid", + "contact_import_row_invalid", + "contact_import_duplicate_email", + "contact_import_normalized_too_large", + "contact_import_file_hash_mismatch", + "contact_import_upload_size_mismatch", + "contact_import_upload_too_large", + ]; + if let Some(reason) = value + .get("error") + .and_then(Value::as_str) + .filter(|s| allowed.contains(s)) + { + safe = json!({"reason":reason}); + if let Some(row) = value + .get("row") + .and_then(Value::as_u64) + .filter(|n| (1..=1_048_576).contains(n)) + { + safe["row"] = json!(row); + } + } + } + } + return Err(Error::Http { + status, + message: "mcp_upload_failed".into(), + body: safe, + }); + } + if storage + && !r + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .is_some_and(|h| { + h.split(';') + .next() + .is_some_and(|v| v.trim() == "application/json") + }) + { + return Err(err("mcp_upload_outcome_unknown")); + } + bounded(r, 4096, None) + .await + .map_err(|_| err("mcp_upload_outcome_unknown")) + }) + .await + .map_err(|_| err("mcp_upload_outcome_unknown"))? + } + + /// Download a storage file, bound to the file ID, version and connection. + pub async fn download_file( + &self, + v: &Value, + options: Option, + ) -> Result, Error> { + if self.app != McpApp::Storage { + return Err(err("mcp_download_not_supported")); + } + if !flag(v, "requiresAuthorization", true) || field(v, "method")? != "GET" { + return Err(err("invalid_mcp_reference")); + } + let file = v.get("file").ok_or_else(|| err("invalid_mcp_reference"))?; + let id = field(file, "id")?; + let n = size(file, "size", DOWNLOAD_CAP)?; + if !hex(id, 24) { + return Err(err("invalid_mcp_reference")); + } + let u = url_for(self, field(v, "downloadUrl")?, 4000)?; + let prefix = format!("{}/files/", resource_path(self)?); + let path = u + .path() + .strip_prefix(&prefix) + .ok_or_else(|| err("invalid_mcp_reference"))?; + let parts: Vec<_> = path.split('/').collect(); + let q = query(&u, &["connection", "version"])?; + if parts.len() != 2 + || !slug(parts[0]) + || parts[1] != id + || !q.get("version").is_some_and(|s| date(s, false)) + { + return Err(err("invalid_mcp_reference")); + } + self.get_file(u, DOWNLOAD_CAP, Some(n), timeout(options, 60_000)?) + .await + } + + /// Download a mail attachment after validating its encoded message reference. + pub async fn download_mail_attachment( + &self, + v: &Value, + options: Option, + ) -> Result, Error> { + if self.app != McpApp::Mail { + return Err(err("mcp_download_not_supported")); + } + if !flag(v, "requiresAuthorization", true) || field(v, "method")? != "GET" { + return Err(err("invalid_mcp_reference")); + } + let max = size(v, "maxBytes", 8 * 1024 * 1024)?; + let a = v + .get("attachment") + .ok_or_else(|| err("invalid_mcp_reference"))?; + size(a, "encodedSize", 12 * 1024 * 1024)?; + let part = field(a, "partId")?; + if max == 0 + || part.len() > 80 + || part.is_empty() + || part + .split('.') + .any(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit())) + { + return Err(err("invalid_mcp_reference")); + } + let u = url_for(self, field(v, "downloadUrl")?, 5000)?; + let prefix = format!("{}/attachments/", resource_path(self)?); + let encoded = u + .path() + .strip_prefix(&prefix) + .ok_or_else(|| err("invalid_mcp_reference"))?; + if encoded.is_empty() + || encoded.len() > 2500 + || !encoded + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(err("invalid_mcp_reference")); + } + let q = query(&u, &["connection", "version"])?; + if !q.get("version").is_some_and(|s| hex(s, 64)) { + return Err(err("invalid_mcp_reference")); + } + let data = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| err("invalid_mcp_reference"))?; + let decoded: Value = + serde_json::from_slice(&data).map_err(|_| err("invalid_mcp_reference"))?; + let obj = decoded + .as_object() + .ok_or_else(|| err("invalid_mcp_reference"))?; + let reference = decoded + .get("reference") + .and_then(Value::as_object) + .ok_or_else(|| err("invalid_mcp_reference"))?; + if obj.len() != 2 + || decoded.get("partId").and_then(Value::as_str) != Some(part) + || reference.len() != 5 + || !["companyId", "mailbox", "folder", "uidValidity", "uid"] + .iter() + .all(|k| reference.contains_key(*k)) + { + return Err(err("invalid_mcp_reference")); + } + let r = decoded + .get("reference") + .ok_or_else(|| err("invalid_mcp_reference"))?; + let company = field(r, "companyId")?; + let mailbox = field(r, "mailbox")?; + let folder = field(r, "folder")?; + let validity = field(r, "uidValidity")?; + let uid = r.get("uid").and_then(Value::as_u64).unwrap_or(0); + if company.is_empty() + || company.len() > 100 + || company.chars().any(|c| matches!(c, '\r' | '\n' | '\0')) + || mailbox.len() > 320 + || mailbox.split('@').count() != 2 + || mailbox + .split('@') + .any(|s| s.is_empty() || s.chars().any(char::is_whitespace)) + || folder.is_empty() + || folder.len() > 512 + || folder.starts_with("__") + || folder.chars().any(|c| matches!(c, '\r' | '\n' | '\0')) + || validity.is_empty() + || validity.len() > 20 + || validity.starts_with('0') + || !validity.bytes().all(|b| b.is_ascii_digit()) + || uid == 0 + || uid > u32::MAX as u64 + { + return Err(err("invalid_mcp_reference")); + } + self.get_file(u, max, None, timeout(options, 60_000)?).await + } + + /// Download and hash-check an original-grant contacts export. + pub async fn download_mail_contacts_export( + &self, + v: &Value, + options: Option, + ) -> Result, Error> { + let (u, n, hash, _) = contact_ref(self, v, "exports")?; + if !matches!(field(v, "format")?, "csv" | "jsonl") { + return Err(err("invalid_mcp_reference")); + } + let data = self + .get_file(u, CONTACT_CAP, Some(n), timeout(options, 60_000)?) + .await?; + if sha(&data) != hash { + return Err(err("mcp_contact_file_hash_mismatch")); + } + Ok(data) + } + + /// Upload an original-grant contacts import. A transport failure returns + /// `mcp_upload_outcome_unknown`; query its state before any further action. + pub async fn upload_mail_contacts_import( + &self, + v: &Value, + body: &[u8], + options: Option, + ) -> Result { + let (u, n, hash, expires) = contact_ref(self, v, "imports")?; + if body.len() != n { + return Err(err("mcp_contact_file_size_mismatch")); + } + if sha(body) != hash { + return Err(err("mcp_contact_file_hash_mismatch")); + } + if !date(&expires, true) { + return Err(err("invalid_mcp_reference")); + } + let data = self + .put_file(u, body, timeout(options, 15_000)?, false, &expires) + .await?; + let result: Value = + serde_json::from_slice(&data).map_err(|_| err("mcp_upload_outcome_unknown"))?; + if result.get("uploadId") != v.get("uploadId") + || result.get("state").and_then(Value::as_str) != Some("ready") + || result.get("fileHash").and_then(Value::as_str) != Some(hash.as_str()) + || result.get("expiresAt").and_then(Value::as_str) != Some(expires.as_str()) + || !result + .get("count") + .and_then(Value::as_u64) + .is_some_and(|n| (1..=100).contains(&n)) + || !date(&expires, true) + { + return Err(err("mcp_upload_outcome_unknown")); + } + Ok( + json!({"uploadId":result["uploadId"],"state":"ready","count":result["count"],"fileHash":hash,"expiresAt":expires}), + ) + } + + /// Upload a storage file under the original grant. A transport failure + /// returns `mcp_upload_outcome_unknown` and is never automatically retried. + pub async fn upload_storage_file( + &self, + v: &Value, + body: &[u8], + options: Option, + ) -> Result { + if self.app != McpApp::Storage { + return Err(err("mcp_storage_upload_not_supported")); + } + let id = field(v, "uploadId")?; + let name = field(v, "name")?; + let mime = field(v, "mimeType")?; + let n = size(v, "bytes", STORAGE_CAP)?; + let hash = field(v, "sha256")?; + let expires = field(v, "expiresAt")?; + let bucket = v + .get("bucket") + .ok_or_else(|| err("invalid_mcp_reference"))?; + if !uuid(id) + || !hex(hash, 64) + || !hex(field(v, "revision")?, 64) + || !date(expires, true) + || !matches!(field(v, "state")?, "waiting" | "ready") + || !flag(v, "published", false) + || !flag(v, "malwareScanned", false) + || !flag(v, "requiresHumanApproval", true) + || !flag(v, "requiresOriginalBearer", true) + || field(v, "method")? != "PUT" + || field(v, "contentType")? != "application/octet-stream" + || !hex(field(bucket, "id")?, 24) + || !slug(field(bucket, "slug")?) + || !hex(field(bucket, "revision")?, 64) + { + return Err(err("invalid_mcp_reference")); + } + let info = super::sentroy_mcp_storage_upload_file_info(body, name, mime)?; + if info.bytes != n { + return Err(err("mcp_storage_upload_size_mismatch")); + } + if info.sha256 != hash { + return Err(err("mcp_storage_upload_hash_mismatch")); + } + let u = url_for(self, field(v, "uploadUrl")?, 4000)?; + if u.path() != format!("{}/uploads/{id}", resource_path(self)?) { + return Err(err("invalid_mcp_reference")); + } + query(&u, &["connection"])?; + if !date(expires, true) { + return Err(err("invalid_mcp_reference")); + } + let data = self + .put_file(u, body, timeout(options, 15_000)?, true, expires) + .await?; + let result: Value = + serde_json::from_slice(&data).map_err(|_| err("mcp_upload_outcome_unknown"))?; + let rb = result + .get("bucket") + .ok_or_else(|| err("mcp_upload_outcome_unknown"))?; + for key in [ + "uploadId", + "name", + "mimeType", + "bytes", + "sha256", + "expiresAt", + ] { + if result.get(key) != v.get(key) { + return Err(err("mcp_upload_outcome_unknown")); + } + } + for key in ["id", "slug", "revision"] { + if rb.get(key) != bucket.get(key) { + return Err(err("mcp_upload_outcome_unknown")); + } + } + if result.get("state").and_then(Value::as_str) != Some("ready") + || !result + .get("revision") + .and_then(Value::as_str) + .is_some_and(|s| hex(s, 64)) + || !flag(&result, "published", false) + || !flag(&result, "malwareScanned", false) + || !flag(&result, "requiresHumanApproval", true) + || !date(expires, true) + { + return Err(err("mcp_upload_outcome_unknown")); + } + Ok( + json!({"uploadId":id,"name":name,"mimeType":mime,"bytes":n,"sha256":hash,"expiresAt":expires, + "bucket":bucket,"state":"ready","revision":result["revision"],"published":false,"malwareScanned":false,"requiresHumanApproval":true}), + ) + } +} diff --git a/rust/src/resources/mail.rs b/rust/src/resources/mail.rs new file mode 100644 index 0000000..9fd1ac2 --- /dev/null +++ b/rust/src/resources/mail.rs @@ -0,0 +1,405 @@ +//! Mail resources. +use crate::http::{query, segment, Http}; +use crate::types::mail::*; +use crate::{Error, Result}; +use serde::Serialize; +use serde_json::{json, Value}; + +fn body(v: &T) -> Result { + Ok(serde_json::to_value(v)?) +} +fn path(base: &str, id: &str) -> Result { + Ok(format!("{}/{}", base, segment(id)?)) +} +fn q(v: &T) -> Result> { + query(v) +} +fn nonempty_bcc(value: &Option) -> bool { + match value { + Some(Addresses::One(s)) => !s.trim().is_empty(), + Some(Addresses::Many(v)) => !v.is_empty(), + None => false, + } +} +fn request_id(value: &Option) -> Result { + match value { + None => Ok(uuid::Uuid::new_v4().to_string()), + Some(v) if v.is_empty() => Ok(uuid::Uuid::new_v4().to_string()), + Some(v) => { + let parsed = uuid::Uuid::parse_str(v) + .map_err(|_| Error::InvalidInput("requestId must be a UUID".into()))?; + let canonical = parsed.to_string(); + if !canonical.eq_ignore_ascii_case(v) + || !(1..=8).contains(&(parsed.as_bytes()[6] >> 4)) + || (parsed.as_bytes()[8] & 0xc0) != 0x80 + { + return Err(Error::InvalidInput("requestId must be a UUID".into())); + } + Ok(canonical) + } + } +} +async fn compose( + http: &Http, + endpoint: &str, + mut data: Value, + id: String, +) -> Result { + data["requestId"] = json!(id); + http.post_once(endpoint, data) + .await + .map_err(|source| Error::ComposeRequest { + request_id: id, + source: Box::new(source), + }) +} +macro_rules! resource { + ($name:ident) => { + #[derive(Clone, Debug)] + pub struct $name { + http: Http, + } + impl $name { + pub(crate) fn new(http: Http) -> Self { + Self { http } + } + } + }; +} +resource!(Domains); +impl Domains { + pub async fn list(&self) -> Result> { + self.http.get("/domains", &[]).await + } + pub async fn get(&self, id: &str) -> Result { + self.http.get(&path("/domains", id)?, &[]).await + } +} +resource!(Mailboxes); +impl Mailboxes { + pub async fn list(&self) -> Result> { + self.http.get("/mailboxes", &[]).await + } +} +resource!(Templates); +impl Templates { + pub async fn list(&self, params: &TemplateListParams) -> Result> { + self.http.get("/templates", &q(params)?).await + } + pub async fn get(&self, id: &str) -> Result