From 691439db00dd221dcd86fcc8ba60de5180bb4fa2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 19:26:37 +0300 Subject: [PATCH 1/5] Add email marketing automation scaffold Co-authored-by: Medulla --- .env.example | 29 +- .gitignore | 5 + Cargo.lock | 2680 +++++++++++++++++++-- Cargo.toml | 17 +- README.md | 167 +- crates/marketing-server/Cargo.toml | 24 + crates/marketing-server/src/analytics.rs | 70 + crates/marketing-server/src/api.rs | 204 ++ crates/marketing-server/src/auth.rs | 133 + crates/marketing-server/src/auth/test.rs | 20 + crates/marketing-server/src/config.rs | 54 + crates/marketing-server/src/error.rs | 69 + crates/marketing-server/src/lib.rs | 23 + crates/marketing-server/src/main.rs | 29 + crates/marketing-server/src/models.rs | 77 + crates/marketing-server/src/repository.rs | 145 ++ crates/marketing-server/src/tinyflows.rs | 49 + dashboard/index.html | 2 + dashboard/package-lock.json | 1254 ++++++++++ dashboard/package.json | 9 + dashboard/src/main.tsx | 36 + dashboard/src/style.css | 1 + dashboard/src/vite-env.d.ts | 1 + dashboard/tsconfig.json | 1 + docs/plans/email-marketing-automation.md | 7 + docs/specs/email-marketing-automation.md | 7 + infra/clickhouse/init.sql | 7 + 27 files changed, 4812 insertions(+), 308 deletions(-) create mode 100644 crates/marketing-server/Cargo.toml create mode 100644 crates/marketing-server/src/analytics.rs create mode 100644 crates/marketing-server/src/api.rs create mode 100644 crates/marketing-server/src/auth.rs create mode 100644 crates/marketing-server/src/auth/test.rs create mode 100644 crates/marketing-server/src/config.rs create mode 100644 crates/marketing-server/src/error.rs create mode 100644 crates/marketing-server/src/lib.rs create mode 100644 crates/marketing-server/src/main.rs create mode 100644 crates/marketing-server/src/models.rs create mode 100644 crates/marketing-server/src/repository.rs create mode 100644 crates/marketing-server/src/tinyflows.rs create mode 100644 dashboard/index.html create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/src/main.tsx create mode 100644 dashboard/src/style.css create mode 100644 dashboard/src/vite-env.d.ts create mode 100644 dashboard/tsconfig.json create mode 100644 docs/plans/email-marketing-automation.md create mode 100644 docs/specs/email-marketing-automation.md create mode 100644 infra/clickhouse/init.sql diff --git a/.env.example b/.env.example index e22ca63..9531a3e 100644 --- a/.env.example +++ b/.env.example @@ -3,17 +3,20 @@ # # cp .env.example .env # $EDITOR .env -# -# Document every environment variable the crate, its tests, or its examples -# read — including optional ones — with a placeholder value and a comment -# saying what it is for. `.env.example` is the contract; `.env` is the secret. - -# Log filter for the `tracing`/`env_logger` style ecosystem. -# RUST_LOG=info - -# Backtraces for local debugging: 1 for a short backtrace, "full" for all frames. -# RUST_BACKTRACE=1 -# Example of a credential a live/network-gated test would need. Tests that -# require one must skip cleanly when it is unset. -# EXAMPLE_API_KEY=replace-me +# MongoDB stores mutable contacts, campaigns, and unsubscribe preferences. +MONGODB_URI=mongodb://localhost:27017 +MONGODB_DATABASE=marketing +# ClickHouse stores immutable campaign lifecycle analytics. +CLICKHOUSE_URL=http://localhost:8123 +CLICKHOUSE_DATABASE=marketing +# TinyFlows webhook trigger for email-marketing lifecycle events. +TINYFLOWS_WEBHOOK_URL=http://localhost:8787/webhooks/email-marketing +# Public API base used when creating unsubscribe URLs. +PUBLIC_BASE_URL=http://localhost:3000 +# Google OAuth credentials and registered authorization-code callback. +GOOGLE_CLIENT_ID=replace-with-google-client-id +GOOGLE_CLIENT_SECRET=replace-with-google-client-secret +GOOGLE_REDIRECT_URL=http://localhost:3000/api/auth/google/callback +# Long random secret used to sign dashboard JWT sessions. +JWT_SECRET=replace-with-a-long-random-secret diff --git a/.gitignore b/.gitignore index 007b32b..3bb564a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ worktrees/* .idea/ .vscode/ *.swp + +# Vite dependencies and generated dashboard build artifacts. +/dashboard/node_modules/ +/dashboard/dist/ +/dashboard/*.tsbuildinfo diff --git a/Cargo.lock b/Cargo.lock index b4f454e..81dd591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,28 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[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 = "arbitrary" version = "1.4.2" @@ -28,6 +50,76 @@ dependencies = [ "syn 3.0.3", ] +[[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 = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[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" @@ -40,6 +132,59 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bson" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" +dependencies = [ + "ahash", + "base64 0.22.1", + "bitvec", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "indexmap", + "js-sys", + "once_cell", + "rand 0.9.5", + "serde", + "serde_bytes", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -69,247 +214,1641 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +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", + "rand_core 0.10.1", ] [[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "cityhash-rs" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "93a719913643003b84bd13022b4b7e703c09342cd03b679c4641c7d2e50dc34d" + +[[package]] +name = "clickhouse" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a9a81a1dffadd762ee662635ce409232258ce9beebd7cc0fa227df0b5e7efc0" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "bstr", + "bytes", + "cityhash-rs", + "clickhouse-derive", + "futures", + "futures-channel", + "http-body-util", + "hyper", + "hyper-util", + "lz4_flex", + "replace_with", + "sealed", + "serde", + "static_assertions", + "thiserror 1.0.69", + "tokio", + "url", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "clickhouse-derive" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "d70f3e2893f7d3e017eeacdc9a708fbc29a10488e3ebca21f9df6a5d2b616dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "serde_derive_internals", + "syn 2.0.119", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] -name = "errno" -version = "0.3.14" +name = "combine" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ - "libc", - "windows-sys 0.61.2", + "bytes", + "memchr", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] -name = "filetime" -version = "0.2.29" +name = "const-random" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" dependencies = [ - "cfg-if", - "libc", + "const-random-macro", ] [[package]] -name = "find-msvc-tools" -version = "0.1.10" +name = "const-random-macro" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] [[package]] -name = "flate2" -version = "1.1.9" +name = "convert_case" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ - "crc32fast", - "miniz_oxide", + "unicode-segmentation", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "cfg-if", + "core-foundation-sys", "libc", - "wasi", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "hashbrown" -version = "0.17.1" +name = "cpufeatures" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] [[package]] -name = "http" +name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "bytes", - "itoa", + "cfg-if", ] [[package]] -name = "httparse" -version = "1.10.1" +name = "critical-section" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] -name = "indexmap" -version = "2.14.0" +name = "crossbeam-channel" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ - "equivalent", - "hashbrown", + "crossbeam-utils", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "crossbeam-epoch" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] [[package]] -name = "libc" -version = "0.2.189" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "log" -version = "0.4.33" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "darling" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "adler2", - "simd-adler32", + "darling_core", + "darling_macro", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "darling_core" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "darling_macro" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "data-encoding" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "quote" -version = "1.0.47" +name = "derive-where" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "ring" -version = "0.17.14" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "derive_more-impl", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "bitflags", + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[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.3", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[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.3", +] + +[[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 = "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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "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 6.0.0", + "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 = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.3", + "thiserror 2.0.20", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.3", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.3", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] + +[[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 2.0.20", + "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 = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +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", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + +[[package]] +name = "macro_magic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" +dependencies = [ + "macro_magic_core", + "macro_magic_macros", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "macro_magic_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" +dependencies = [ + "const-random", + "derive-syn-parse", + "macro_magic_core_macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "macro_magic_core_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "macro_magic_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" +dependencies = [ + "macro_magic_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "marketing-server" +version = "0.2.1" +dependencies = [ + "axum", + "chrono", + "clickhouse", + "jsonwebtoken", + "mongodb", + "reqwest", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[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 = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mongocrypt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8426a875ded61430d4a811dbfda7633b6b8af0225c547fc6c28b8b0aa7d79a13" +dependencies = [ + "bson", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.6+1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" + +[[package]] +name = "mongodb" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af84443ae9878f59a6818686f339064479f42cdf293c4c637d8fc9c0a8c5eed6" +dependencies = [ + "base64 0.22.1", + "bitflags", + "bson", + "derive-where", + "derive_more", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hickory-net", + "hickory-proto", + "hickory-resolver", + "hmac", + "macro_magic", + "md-5", + "mongocrypt", + "mongodb-internal-macros", + "pbkdf2", + "percent-encoding", + "rand 0.9.5", + "rustc_version_runtime", + "rustls", + "serde", + "serde_bytes", + "serde_with", + "sha1", + "sha2", + "socket2", + "stringprep", + "strsim", + "take_mut", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-util", + "typed-builder", + "uuid", + "webpki-roots", +] + +[[package]] +name = "mongodb-internal-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504024bbb83ab1bf1512007f7288b54d3a6343043f3f619c26a153346773598d" +dependencies = [ + "macro_magic", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[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" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[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 = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[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 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[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", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[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 = "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 = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", "errno", "libc", "linux-raw-sys", @@ -332,25 +1871,70 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.15.1" +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-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "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 = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ - "zeroize", + "winapi-util", ] [[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "proc-macro2", + "quote", + "syn 2.0.119", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -361,6 +1945,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -381,12 +1975,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_json" version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -394,6 +2000,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -403,18 +2020,163 @@ dependencies = [ "serde", ] +[[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 = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "serde_core", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[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 = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -443,6 +2205,65 @@ dependencies = [ "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.3", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -473,7 +2294,7 @@ version = "0.2.1" dependencies = [ "serde_json", "template-bus", - "thiserror", + "thiserror 2.0.20", "tinybus", "tinybus-module", "tokio", @@ -487,13 +2308,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -507,9 +2348,48 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinybus" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "flate2", @@ -517,7 +2397,7 @@ dependencies = [ "serde_json", "tar", "tempfile", - "thiserror", + "thiserror 2.0.20", "tinybus-macros", "tokio", "toml", @@ -528,7 +2408,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.0" +version = "0.1.1" dependencies = [ "proc-macro2", "quote", @@ -537,7 +2417,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "serde", @@ -547,6 +2427,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +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.53.1" @@ -554,8 +2450,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -569,6 +2470,31 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -591,24 +2517,70 @@ dependencies = [ ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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", + "tracing", +] + +[[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 = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "toml_write" -version = "0.1.2" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -616,6 +2588,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -641,12 +2614,77 @@ 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 = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -659,7 +2697,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -676,24 +2714,163 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", ] +[[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-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.3", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +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-roots" version = "1.0.9" @@ -703,12 +2880,91 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[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-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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" @@ -800,6 +3056,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xattr" version = "1.6.1" @@ -810,12 +3087,109 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +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.3", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zip" version = "2.4.2" @@ -829,7 +3203,7 @@ dependencies = [ "flate2", "indexmap", "memchr", - "thiserror", + "thiserror 2.0.20", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index fae3c38..20d50b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,23 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } # Positional argument arrays and the module configuration blob. serde_json = "1" +# HTTP routing and response extraction for the marketing automation API. +axum = "0.8" +# Tokio powers Axum, MongoDB, and outbound TinyFlows webhook delivery. +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "net", "signal"] } +# Async MongoDB persistence for contacts, campaigns, and suppression state. +mongodb = "3.2" +# ISO-8601 timestamps stored with marketing records. +chrono = { version = "0.4", features = ["serde"] } +# HTTP client used by the TinyFlows webhook adapter. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# Native ClickHouse client used for immutable engagement analytics events. +clickhouse = "0.13" +# Signed API session tokens issued after a verified Google OAuth identity. +jsonwebtoken = "9" +# URL encoding for Google OAuth authorization URLs. +url = "2" # Module integration tests exercise the real asynchronous in-memory TinyBus. -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } # Lints apply to every member that opts in with `[lints] workspace = true`, and # to every target of that member. CI runs clippy with `-D warnings`, so anything diff --git a/README.md b/README.md index 67a4e39..25e623e 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,41 @@ -# Rust Template +# Chimpboard -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the workspace layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. +Email marketing automation scaffolding inspired by the operational shape of +Mautic, Mailchimp, and Loops: contacts and campaigns in MongoDB, immutable +analytics in ClickHouse, and lifecycle automation handed off to TinyFlows. -It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — -member names, payload types, and the contract version, with no transport and no -behavior — and `crates/template` is the implementation, built as both an `rlib` -and the `cdylib` TinyBus loads. A host that only makes calls depends on the -contract crate alone and compiles neither the module nor `tinybus` itself. +## Run locally -## Use This Template +Copy `.env.example` to `.env`, fill in infrastructure and Google OAuth values, +then start the API with `cargo run -p marketing-server`. Initialize ClickHouse +with `infra/clickhouse/init.sql`, and start the dashboard with `cd dashboard && +npm install && npm run dev`. -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): +The API provides `POST /api/contacts`, `POST /api/campaigns`, +`POST /api/campaigns/{id}/launch`, and the public `GET|POST /unsubscribe/{token}` +page. Point `TINYFLOWS_WEBHOOK_URL` at a TinyFlows webhook trigger; it receives +an `email-marketing` workflow envelope and lifecycle event data. -- rename the `crates/template` and `crates/template-bus` directories and the - `name` fields in their manifests, and set the shared `description`, - `repository`, `keywords`, and `categories`; -- update this README and the crate documentation in `crates/template/src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area, in - both crates: the payload types in the contract, the behavior in the module; -- rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. +## Architecture -Search for `template` and `template_bus` to find every remaining -template-specific value. +- `crates/marketing-server` is the Axum service. MongoDB owns contact and + campaign state; ClickHouse owns append-only analytics. +- `dashboard/` is a small React + Vite operator console. +- `infra/clickhouse/init.sql` creates the analytics event table. +- `docs/specs/email-marketing-automation.md` records the security and delivery + boundary; the delivery implementation belongs in a TinyFlows flow. -## What You Get +The Google callback currently returns the signed JWT as JSON so the dashboard +can choose its own token storage policy. Before public deployment, add a +server-side, short-lived OAuth state store and JWT middleware to protect every +operator-only route. -| Area | What is configured | -| --- | --- | -| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | -| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | - -## Layout - -```text -Cargo.toml # virtual workspace: members, shared metadata, lints -crates/ -├── template-bus/ # the wire contract — what crosses the bus -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── greeting/ # payload types, one directory per family -│ │ ├── mod.rs -│ │ ├── types.rs -│ │ └── test.rs -│ └── version/ # contract version and the host bind rule -└── template/ # the module — behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/ # crate-wide `Error` and `Result` - │ ├── greeting/ # one directory per feature area - │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports - ├── tests/ - │ └── public_api.rs # integration tests against the public API only - └── examples/ - ├── basic.rs # ordinary library API usage - ├── verify_module.rs # local dynamic-module verification - └── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records -``` - -The split is the point. A payload type describes what a frame carries; the -behavior that answers it is a different obligation. `template` depends on -`template-bus` and re-exports all of it, so `template::GreetRequest` and -`template_bus::GreetRequest` are the *same* type rather than structural twins, -and a host is never forced to choose between linking the whole module and -redefining the vocabulary. See -[`crates/template-bus/README.md`](crates/template-bus/README.md). - -Within each crate, feature areas use directory modules: implementation and -exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests -live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository -guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one -source of truth. - -## Development - -Clone with submodules, or initialize them before building: - -```sh -git submodule update --init --recursive -``` +## Validation ```sh cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features -cargo run -p template --example basic -cargo build -p template --release --lib # produces the installable cdylib +cd dashboard && npm run build ``` - -Those four checks are exactly what CI runs. Optional extras: - -```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 coverage.json -``` - -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the workspace, versions -and tags it — one `[workspace.package]` version that every member inherits — -builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `template--.` and contain the -native module, its SHA-256 `modules.toml`, license, and -[`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which -TinyBus uses to verify an archive before extraction. The workflow loads the -published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in the -root `Cargo.toml`. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability - -## License - -GPL-3.0-only. See [LICENSE](LICENSE). diff --git a/crates/marketing-server/Cargo.toml b/crates/marketing-server/Cargo.toml new file mode 100644 index 0000000..bf6d490 --- /dev/null +++ b/crates/marketing-server/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "marketing-server" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Axum and MongoDB foundation for email marketing automation." +publish = false + +[dependencies] +axum = { workspace = true } +chrono = { workspace = true } +clickhouse = { workspace = true } +jsonwebtoken = { workspace = true } +mongodb = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } + +[lints] +workspace = true diff --git a/crates/marketing-server/src/analytics.rs b/crates/marketing-server/src/analytics.rs new file mode 100644 index 0000000..7caf6d2 --- /dev/null +++ b/crates/marketing-server/src/analytics.rs @@ -0,0 +1,70 @@ +//! ClickHouse-backed immutable engagement analytics. + +use std::fmt; + +use chrono::{DateTime, Utc}; +use clickhouse::{Client, Row}; +use serde::Serialize; + +use crate::error::Result; + +/// An event written to the `ClickHouse` `campaign_events` table. +#[derive(Clone, Debug, Row, Serialize)] +pub struct CampaignEvent { + /// Event kind, such as `contact_unsubscribed`. + pub event_name: String, + /// Associated campaign identifier when available. + pub campaign_id: String, + /// Associated contact identifier when available. + pub contact_id: String, + /// Event timestamp in UTC. + pub occurred_at: DateTime, +} + +/// Appends engagement events to `ClickHouse`. +#[derive(Clone)] +pub struct AnalyticsWriter { + client: Client, +} + +impl AnalyticsWriter { + /// Builds a writer for a `ClickHouse` database. + #[must_use] + pub fn new(url: &str, database: &str) -> Self { + Self { + client: Client::default().with_url(url).with_database(database), + } + } + + /// Records an immutable campaign event. + /// + /// The database must provide a `campaign_events` table matching + /// [`CampaignEvent`]. + /// + /// # Errors + /// + /// Returns an error if `ClickHouse` cannot accept the event. + pub async fn record(&self, event: &CampaignEvent) -> Result<()> { + let mut insert = self + .client + .insert("campaign_events") + .map_err(|error| crate::error::Error::Validation(error.to_string()))?; + insert + .write(event) + .await + .map_err(|error| crate::error::Error::Validation(error.to_string()))?; + insert + .end() + .await + .map_err(|error| crate::error::Error::Validation(error.to_string()))?; + Ok(()) + } +} + +impl fmt::Debug for AnalyticsWriter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AnalyticsWriter") + .finish_non_exhaustive() + } +} diff --git a/crates/marketing-server/src/api.rs b/crates/marketing-server/src/api.rs new file mode 100644 index 0000000..336264e --- /dev/null +++ b/crates/marketing-server/src/api.rs @@ -0,0 +1,204 @@ +//! Axum routes for the dashboard, automation service, and public unsubscribe page. + +use std::{fmt, sync::Arc}; + +use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Redirect}, + routing::{get, post}, +}; +use chrono::Utc; +use mongodb::Database; +use serde::{Deserialize, Serialize}; + +use crate::{ + AnalyticsWriter, GoogleOAuth, MarketingRepository, TinyFlowsClient, + analytics::CampaignEvent, + error::Result, + models::{CreateCampaign, CreateContact}, +}; + +/// Shared dependencies for Axum handlers. +#[derive(Clone)] +pub struct AppState { + repository: MarketingRepository, + workflows: TinyFlowsClient, + analytics: AnalyticsWriter, + auth: GoogleOAuth, + public_base_url: String, +} + +impl AppState { + /// Builds state and initializes MongoDB indexes. + /// + /// # Errors + /// + /// Returns an error if MongoDB index initialization fails. + pub async fn new( + database: Database, + workflows: TinyFlowsClient, + analytics: AnalyticsWriter, + auth: GoogleOAuth, + public_base_url: String, + ) -> Result { + Ok(Self { + repository: MarketingRepository::new(database).await?, + workflows, + analytics, + auth, + public_base_url, + }) + } +} + +/// Creates the application router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/api/contacts", post(create_contact)) + .route("/api/campaigns", post(create_campaign)) + .route("/api/campaigns/{id}/launch", post(launch_campaign)) + .route("/api/auth/google", get(google_login)) + .route("/api/auth/google/callback", get(google_callback)) + .route( + "/unsubscribe/{token}", + get(unsubscribe_page).post(unsubscribe), + ) + .with_state(Arc::new(state)) +} + +async fn health() -> StatusCode { + StatusCode::NO_CONTENT +} + +async fn create_contact( + State(state): State>, + Json(input): Json, +) -> Result { + let contact = state.repository.create_contact(input).await?; + state.workflows.trigger("contact.created", &contact).await?; + Ok(( + StatusCode::CREATED, + Json(ContactResponse::from_contact( + &contact, + &state.public_base_url, + )), + )) +} + +async fn create_campaign( + State(state): State>, + Json(input): Json, +) -> Result { + let campaign = state.repository.create_campaign(input).await?; + Ok((StatusCode::CREATED, Json(campaign))) +} + +async fn launch_campaign( + State(state): State>, + Path(id): Path, +) -> Result { + let campaign = state.repository.launch_campaign(&id).await?; + state + .workflows + .trigger("campaign.launched", &campaign) + .await?; + state + .analytics + .record(&CampaignEvent { + event_name: "campaign_launched".into(), + campaign_id: id, + contact_id: String::new(), + occurred_at: Utc::now(), + }) + .await?; + Ok(Json(campaign)) +} + +#[derive(Debug, Deserialize)] +struct LoginQuery { + state: String, +} + +async fn google_login( + State(state): State>, + Query(query): Query, +) -> Redirect { + Redirect::temporary(&state.auth.authorization_url(&query.state)) +} + +#[derive(Debug, Deserialize)] +struct CallbackQuery { + code: String, + state: String, +} + +async fn google_callback( + State(state): State>, + Query(query): Query, +) -> Result> { + if query.state.trim().is_empty() { + return Err(crate::Error::Validation("OAuth state is required".into())); + } + Ok(Json(state.auth.exchange_code(&query.code).await?)) +} + +async fn unsubscribe_page(Path(token): Path) -> Html { + Html(format!( + "Unsubscribe

Unsubscribe from email

You can stop future marketing email with one click.

" + )) +} + +async fn unsubscribe( + State(state): State>, + Path(token): Path, +) -> Result> { + let contact = state.repository.unsubscribe(&token).await?; + state + .workflows + .trigger("contact.unsubscribed", &contact) + .await?; + state + .analytics + .record(&CampaignEvent { + event_name: "contact_unsubscribed".into(), + campaign_id: String::new(), + contact_id: contact + .id + .map_or_else(String::new, mongodb::bson::oid::ObjectId::to_hex), + occurred_at: Utc::now(), + }) + .await?; + Ok(Html( + "Unsubscribed

You are unsubscribed

You will no longer receive marketing email from us.

", + )) +} + +impl fmt::Debug for AppState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("AppState").finish_non_exhaustive() + } +} + +#[derive(Debug, Serialize)] +struct ContactResponse { + email: String, + subscribed: bool, + unsubscribe_url: String, +} + +impl ContactResponse { + fn from_contact(contact: &crate::models::Contact, public_base_url: &str) -> Self { + Self { + email: contact.email.clone(), + subscribed: contact.subscribed, + unsubscribe_url: format!( + "{}/unsubscribe/{}", + public_base_url.trim_end_matches('/'), + contact.unsubscribe_token + ), + } + } +} diff --git a/crates/marketing-server/src/auth.rs b/crates/marketing-server/src/auth.rs new file mode 100644 index 0000000..300ffa5 --- /dev/null +++ b/crates/marketing-server/src/auth.rs @@ -0,0 +1,133 @@ +//! Google OAuth authorization-code exchange and dashboard JWT issuance. + +use chrono::{Duration, Utc}; +use jsonwebtoken::{EncodingKey, Header, encode}; +use serde::{Deserialize, Serialize}; +use url::form_urlencoded; + +use crate::error::Result; + +/// Google OAuth client and JWT session issuer. +#[derive(Clone, Debug)] +pub struct GoogleOAuth { + client_id: String, + client_secret: String, + redirect_url: String, + jwt_secret: String, +} + +/// Response returned after a successful OAuth callback. +#[derive(Debug, Serialize)] +pub struct Session { + /// Signed dashboard API token. + pub token: String, + /// Authenticated user's email. + pub email: String, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, +} +#[derive(Debug, Deserialize)] +struct GoogleUser { + sub: String, + email: String, + email_verified: bool, +} +#[derive(Debug, Serialize)] +struct Claims { + sub: String, + email: String, + exp: i64, + iat: i64, +} + +impl GoogleOAuth { + /// Creates an OAuth helper from configured credentials. + #[must_use] + pub fn new( + client_id: String, + client_secret: String, + redirect_url: String, + jwt_secret: String, + ) -> Self { + Self { + client_id, + client_secret, + redirect_url, + jwt_secret, + } + } + + /// Builds the Google consent URL for a CSRF state token. + #[must_use] + pub fn authorization_url(&self, state: &str) -> String { + let query = form_urlencoded::Serializer::new(String::new()) + .extend_pairs([ + ("client_id", self.client_id.as_str()), + ("redirect_uri", self.redirect_url.as_str()), + ("response_type", "code"), + ("scope", "openid email profile"), + ("state", state), + ]) + .finish(); + format!("https://accounts.google.com/o/oauth2/v2/auth?{query}") + } + + /// Exchanges a Google authorization code and issues a 24-hour API JWT. + /// + /// # Errors + /// + /// Returns an error if Google rejects the code or the JWT cannot be signed. + pub async fn exchange_code(&self, code: &str) -> Result { + let http = reqwest::Client::new(); + let token = http + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("code", code), + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("redirect_uri", self.redirect_url.as_str()), + ("grant_type", "authorization_code"), + ]) + .send() + .await? + .error_for_status()? + .json::() + .await?; + let user = http + .get("https://openidconnect.googleapis.com/v1/userinfo") + .bearer_auth(token.access_token) + .send() + .await? + .error_for_status()? + .json::() + .await?; + if !user.email_verified { + return Err(crate::error::Error::Validation( + "Google account email is not verified".into(), + )); + } + let now = Utc::now(); + let claims = Claims { + sub: user.sub, + email: user.email.clone(), + iat: now.timestamp(), + exp: (now + Duration::hours(24)).timestamp(), + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_bytes()), + ) + .map_err(|error| crate::error::Error::Validation(error.to_string()))?; + Ok(Session { + token, + email: user.email, + }) + } +} + +#[cfg(test)] +mod test; diff --git a/crates/marketing-server/src/auth/test.rs b/crates/marketing-server/src/auth/test.rs new file mode 100644 index 0000000..370a5e7 --- /dev/null +++ b/crates/marketing-server/src/auth/test.rs @@ -0,0 +1,20 @@ +//! Tests for Google OAuth authorization URL construction. + +use super::GoogleOAuth; + +#[test] +fn authorization_url_contains_encoded_oauth_parameters() { + let oauth = GoogleOAuth::new( + "client id".into(), + "secret".into(), + "https://dashboard.example.test/callback".into(), + "jwt-secret".into(), + ); + + let url = oauth.authorization_url("random state"); + + assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?")); + assert!(url.contains("client_id=client+id")); + assert!(url.contains("redirect_uri=https%3A%2F%2Fdashboard.example.test%2Fcallback")); + assert!(url.contains("state=random+state")); +} diff --git a/crates/marketing-server/src/config.rs b/crates/marketing-server/src/config.rs new file mode 100644 index 0000000..ce1793d --- /dev/null +++ b/crates/marketing-server/src/config.rs @@ -0,0 +1,54 @@ +//! Environment-backed application configuration. + +use crate::error::{Error, Result}; + +/// Configuration required to start the API. +#[derive(Clone, Debug)] +pub struct Config { + /// MongoDB connection URI. + pub mongodb_uri: String, + /// MongoDB database name. + pub mongodb_database: String, + /// `TinyFlows` webhook endpoint receiving lifecycle events. + pub tinyflows_webhook_url: String, + /// Public URL used when producing unsubscribe links. + pub public_base_url: String, + /// `ClickHouse` HTTP URL used for engagement analytics. + pub clickhouse_url: String, + /// `ClickHouse` database holding the event table. + pub clickhouse_database: String, + /// Google OAuth client identifier. + pub google_client_id: String, + /// Google OAuth client secret. + pub google_client_secret: String, + /// Registered Google OAuth redirect URL. + pub google_redirect_url: String, + /// Secret used to sign dashboard JWTs. + pub jwt_secret: String, +} + +impl Config { + /// Reads configuration from environment variables. + /// + /// # Errors + /// + /// Returns validation errors for missing required variables. + pub fn from_env() -> Result { + fn required(name: &str) -> Result { + std::env::var(name).map_err(|_| Error::Validation(format!("missing required {name}"))) + } + Ok(Self { + mongodb_uri: required("MONGODB_URI")?, + mongodb_database: required("MONGODB_DATABASE")?, + tinyflows_webhook_url: required("TINYFLOWS_WEBHOOK_URL")?, + public_base_url: std::env::var("PUBLIC_BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()), + clickhouse_url: required("CLICKHOUSE_URL")?, + clickhouse_database: required("CLICKHOUSE_DATABASE")?, + google_client_id: required("GOOGLE_CLIENT_ID")?, + google_client_secret: required("GOOGLE_CLIENT_SECRET")?, + google_redirect_url: required("GOOGLE_REDIRECT_URL")?, + jwt_secret: required("JWT_SECRET")?, + }) + } +} diff --git a/crates/marketing-server/src/error.rs b/crates/marketing-server/src/error.rs new file mode 100644 index 0000000..6c34d56 --- /dev/null +++ b/crates/marketing-server/src/error.rs @@ -0,0 +1,69 @@ +//! Crate-wide error responses. + +use std::fmt; + +use axum::{ + Json, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +/// Errors returned by the marketing automation service. +#[derive(Debug)] +pub enum Error { + /// MongoDB rejected an operation. + Database(mongodb::error::Error), + /// An API request did not meet the contract. + Validation(String), + /// A requested resource does not exist. + NotFound(String), + /// `TinyFlows` could not accept an automation event. + Workflow(reqwest::Error), +} + +/// Convenient result alias for service operations. +pub type Result = std::result::Result; + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: String, +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let (status, error) = match self { + Self::Database(error) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + error.to_string(), + ), + Self::Validation(error) => (axum::http::StatusCode::BAD_REQUEST, error), + Self::NotFound(error) => (axum::http::StatusCode::NOT_FOUND, error), + Self::Workflow(error) => (axum::http::StatusCode::BAD_GATEWAY, error.to_string()), + }; + (status, Json(ErrorBody { error })).into_response() + } +} + +impl From for Error { + fn from(error: mongodb::error::Error) -> Self { + Self::Database(error) + } +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Self::Workflow(error) + } +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Database(error) => write!(formatter, "database error: {error}"), + Self::Validation(error) | Self::NotFound(error) => formatter.write_str(error), + Self::Workflow(error) => write!(formatter, "workflow error: {error}"), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/marketing-server/src/lib.rs b/crates/marketing-server/src/lib.rs new file mode 100644 index 0000000..c97df83 --- /dev/null +++ b/crates/marketing-server/src/lib.rs @@ -0,0 +1,23 @@ +//! Email marketing automation service primitives. +//! +//! This crate provides an Axum API backed by MongoDB, with an outbound +//! `TinyFlows` webhook adapter for campaign lifecycle automation. It deliberately +//! does not send email itself: delivery providers belong behind a dedicated +//! worker or `TinyFlows` flow. + +pub mod analytics; +pub mod api; +pub mod auth; +pub mod config; +pub mod error; +pub mod models; +pub mod repository; +pub mod tinyflows; + +pub use analytics::AnalyticsWriter; +pub use api::{AppState, router}; +pub use auth::GoogleOAuth; +pub use config::Config; +pub use error::{Error, Result}; +pub use repository::MarketingRepository; +pub use tinyflows::TinyFlowsClient; diff --git a/crates/marketing-server/src/main.rs b/crates/marketing-server/src/main.rs new file mode 100644 index 0000000..0679d1f --- /dev/null +++ b/crates/marketing-server/src/main.rs @@ -0,0 +1,29 @@ +//! Executable entrypoint for the marketing automation API. + +use marketing_server::{AnalyticsWriter, AppState, Config, GoogleOAuth, TinyFlowsClient, router}; +use mongodb::Client; + +/// Starts the Axum server using environment-backed infrastructure settings. +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = Config::from_env()?; + let database = Client::with_uri_str(&config.mongodb_uri) + .await? + .database(&config.mongodb_database); + let state = AppState::new( + database, + TinyFlowsClient::new(config.tinyflows_webhook_url), + AnalyticsWriter::new(&config.clickhouse_url, &config.clickhouse_database), + GoogleOAuth::new( + config.google_client_id, + config.google_client_secret, + config.google_redirect_url, + config.jwt_secret, + ), + config.public_base_url, + ) + .await?; + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; + axum::serve(listener, router(state)).await?; + Ok(()) +} diff --git a/crates/marketing-server/src/models.rs b/crates/marketing-server/src/models.rs new file mode 100644 index 0000000..c2f7946 --- /dev/null +++ b/crates/marketing-server/src/models.rs @@ -0,0 +1,77 @@ +//! API and persistence model definitions. + +use chrono::{DateTime, Utc}; +use mongodb::bson::oid::ObjectId; +use serde::{Deserialize, Serialize}; + +/// Request to create a contact. +#[derive(Debug, Deserialize)] +pub struct CreateContact { + /// Email address to subscribe. + pub email: String, + /// Display name used for personalization. + pub first_name: Option, +} + +/// A marketing contact persisted in MongoDB. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Contact { + /// MongoDB document identifier. + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Contact email address. + pub email: String, + /// Optional display name. + pub first_name: Option, + /// Whether the contact can receive marketing email. + pub subscribed: bool, + /// Opaque token embedded in the unsubscribe URL. + pub unsubscribe_token: String, + /// Record creation time. + pub created_at: DateTime, + /// Most recent subscription preference change. + pub updated_at: DateTime, +} + +/// Request to create a campaign draft. +#[derive(Debug, Deserialize)] +pub struct CreateCampaign { + /// Dashboard-facing campaign title. + pub name: String, + /// Email subject line. + pub subject: String, + /// HTML email body. It must include the supplied unsubscribe URL placeholder. + pub html_body: String, +} + +/// A campaign draft or launched campaign. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Campaign { + /// MongoDB document identifier. + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Dashboard-facing campaign title. + pub name: String, + /// Email subject line. + pub subject: String, + /// HTML email body. + pub html_body: String, + /// Current lifecycle state. + pub status: CampaignStatus, + /// Record creation time. + pub created_at: DateTime, + /// Launch time, if the flow was started. + pub launched_at: Option>, +} + +/// Campaign lifecycle state. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CampaignStatus { + /// Campaign can be edited but has not entered an automation flow. + Draft, + /// Launch request was accepted and delivery automation is being started. + Launching, + /// Campaign delivery automation has started. + Launched, +} diff --git a/crates/marketing-server/src/repository.rs b/crates/marketing-server/src/repository.rs new file mode 100644 index 0000000..1bda697 --- /dev/null +++ b/crates/marketing-server/src/repository.rs @@ -0,0 +1,145 @@ +//! MongoDB persistence and index management. + +use chrono::Utc; +use mongodb::{ + Collection, Database, IndexModel, + bson::{doc, oid::ObjectId}, + options::IndexOptions, +}; + +use crate::{ + error::{Error, Result}, + models::{Campaign, CampaignStatus, Contact, CreateCampaign, CreateContact}, +}; + +/// MongoDB repository for contacts and campaigns. +#[derive(Clone, Debug)] +pub struct MarketingRepository { + contacts: Collection, + campaigns: Collection, +} + +impl MarketingRepository { + /// Creates a repository and its required unique indexes. + /// + /// # Errors + /// + /// Returns an error if MongoDB cannot create an index. + pub async fn new(database: Database) -> Result { + let contacts = database.collection("contacts"); + let campaigns = database.collection("campaigns"); + contacts + .create_index( + IndexModel::builder() + .keys(doc! { "email": 1 }) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + contacts + .create_index( + IndexModel::builder() + .keys(doc! { "unsubscribe_token": 1 }) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + Ok(Self { + contacts, + campaigns, + }) + } + + /// Inserts a subscribed contact. + /// + /// # Errors + /// + /// Returns validation or database errors. + pub async fn create_contact(&self, input: CreateContact) -> Result { + let email = input.email.trim().to_lowercase(); + if !email.contains('@') { + return Err(Error::Validation("email must contain @".into())); + } + let now = Utc::now(); + let mut contact = Contact { + id: None, + email, + first_name: input.first_name.filter(|name| !name.trim().is_empty()), + subscribed: true, + unsubscribe_token: ObjectId::new().to_hex(), + created_at: now, + updated_at: now, + }; + let inserted = self + .contacts + .insert_one(&contact) + .await? + .inserted_id + .as_object_id() + .ok_or_else(|| { + Error::Database(mongodb::error::Error::custom( + "MongoDB did not return an object id", + )) + })?; + contact.id = Some(inserted); + Ok(contact) + } + + /// Inserts a campaign in draft state. + /// + /// # Errors + /// + /// Returns validation or database errors. + pub async fn create_campaign(&self, input: CreateCampaign) -> Result { + if input.name.trim().is_empty() || input.subject.trim().is_empty() { + return Err(Error::Validation( + "campaign name and subject are required".into(), + )); + } + let mut campaign = Campaign { + id: None, + name: input.name, + subject: input.subject, + html_body: input.html_body, + status: CampaignStatus::Draft, + created_at: Utc::now(), + launched_at: None, + }; + let inserted = self + .campaigns + .insert_one(&campaign) + .await? + .inserted_id + .as_object_id() + .ok_or_else(|| { + Error::Database(mongodb::error::Error::custom( + "MongoDB did not return an object id", + )) + })?; + campaign.id = Some(inserted); + Ok(campaign) + } + + /// Marks a campaign as launched and returns it. + /// + /// # Errors + /// + /// Returns not-found or database errors. + pub async fn launch_campaign(&self, id: &str) -> Result { + let id = ObjectId::parse_str(id) + .map_err(|_| Error::Validation("campaign id is invalid".into()))?; + let now = Utc::now(); + let campaign = self.campaigns.find_one_and_update(doc! { "_id": id }, doc! { "$set": { "status": "launching", "launched_at": mongodb::bson::DateTime::from_millis(now.timestamp_millis()) } }).return_document(mongodb::options::ReturnDocument::After).await?; + campaign.ok_or_else(|| Error::NotFound("campaign not found".into())) + } + + /// Removes a contact from marketing delivery by token. + /// + /// # Errors + /// + /// Returns not-found or database errors. + pub async fn unsubscribe(&self, token: &str) -> Result { + let contact = self.contacts.find_one_and_update(doc! { "unsubscribe_token": token }, doc! { "$set": { "subscribed": false, "updated_at": mongodb::bson::DateTime::from_millis(Utc::now().timestamp_millis()) } }).return_document(mongodb::options::ReturnDocument::After).await?; + contact.ok_or_else(|| Error::NotFound("unsubscribe link is invalid".into())) + } +} diff --git a/crates/marketing-server/src/tinyflows.rs b/crates/marketing-server/src/tinyflows.rs new file mode 100644 index 0000000..00c5e92 --- /dev/null +++ b/crates/marketing-server/src/tinyflows.rs @@ -0,0 +1,49 @@ +//! `TinyFlows` webhook integration. + +use serde::Serialize; + +use crate::error::Result; + +/// Sends marketing lifecycle events to a `TinyFlows` webhook-triggered flow. +#[derive(Clone, Debug)] +pub struct TinyFlowsClient { + webhook_url: String, + http: reqwest::Client, +} + +#[derive(Debug, Serialize)] +struct WebhookEnvelope<'a, T: Serialize> { + workflow: &'a str, + event: &'a str, + data: &'a T, +} + +impl TinyFlowsClient { + /// Creates a webhook client for one `TinyFlows` ingress endpoint. + #[must_use] + pub fn new(webhook_url: String) -> Self { + Self { + webhook_url, + http: reqwest::Client::new(), + } + } + + /// Delivers a named event to the `email-marketing` `TinyFlows` workflow. + /// + /// # Errors + /// + /// Returns an error when `TinyFlows` rejects or cannot receive the event. + pub async fn trigger(&self, event: &str, data: &T) -> Result<()> { + self.http + .post(&self.webhook_url) + .json(&WebhookEnvelope { + workflow: "email-marketing", + event, + data, + }) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..c941ccf --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,2 @@ + +Chimpboard
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..88bd73b --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,1254 @@ +{ + "name": "marketing-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "marketing-dashboard", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "latest", + "react": "latest", + "react-dom": "latest", + "vite": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "typescript": "latest" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..64dcc6d --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,9 @@ +{ + "name": "marketing-dashboard", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" }, + "dependencies": { "@vitejs/plugin-react": "latest", "vite": "latest", "react": "latest", "react-dom": "latest" }, + "devDependencies": { "typescript": "latest", "@types/react": "latest", "@types/react-dom": "latest" } +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..60c2606 --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,36 @@ +import { FormEvent, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import './style.css'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; + +function App() { + const [email, setEmail] = useState(''); + const [campaignName, setCampaignName] = useState('Welcome series'); + const [notice, setNotice] = useState(''); + + async function addContact(event: FormEvent) { + event.preventDefault(); + const response = await fetch(`${API}/api/contacts`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }) }); + setNotice(response.ok ? 'Contact subscribed and TinyFlows notified.' : 'Unable to add the contact.'); + } + + async function createCampaign(event: FormEvent) { + event.preventDefault(); + const response = await fetch(`${API}/api/campaigns`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: campaignName, subject: 'A note from Chimpboard', html_body: '

Hello!

Unsubscribe

' }) }); + setNotice(response.ok ? 'Campaign draft created.' : 'Unable to create the campaign.'); + } + + return
+ +

EMAIL AUTOMATION

Build campaigns people want to receive.

Audience management, automation handoffs, and a respectful unsubscribe path in one control room.

+
0Active contacts
0Campaigns sent
Open rate
+
+

Add a contact

+

Start a campaign

+
+ {notice &&

{notice}

} +
; +} + +createRoot(document.getElementById('root')!).render(); diff --git a/dashboard/src/style.css b/dashboard/src/style.css new file mode 100644 index 0000000..ffb66b7 --- /dev/null +++ b/dashboard/src/style.css @@ -0,0 +1 @@ +:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #15231c; background: #f7f5ed; } * { box-sizing: border-box; } body { margin: 0; } main { max-width: 1080px; margin: auto; padding: 24px; } nav { display:flex; align-items:center; gap:24px; padding: 10px 0 48px; } nav strong { font-size: 1.4rem; margin-right:auto; color:#136f4b; } nav a { background:#15231c; color:white; border-radius:8px; padding:10px 14px; text-decoration:none; } .hero { max-width:720px; padding:42px 0; } .eyebrow { color:#16774f; font-weight:700; letter-spacing:.1em; font-size:.75rem; } h1 { font-size:clamp(2.8rem, 8vw, 5.5rem); line-height:.94; letter-spacing:-.06em; margin:.25rem 0 1.5rem; } .hero p:last-child { font-size:1.2rem; line-height:1.6; color:#526057; } .metrics, .forms { display:grid; grid-template-columns:repeat(3, 1fr); gap:16px; margin:28px 0; } article, form { background:white; border:1px solid #e4e1d5; border-radius:16px; padding:24px; } article b { font-size:2.5rem; display:block; } article span { color:#68746c; } .forms { grid-template-columns:repeat(2, 1fr); } label { display:grid; gap:8px; color:#45524a; } input { font:inherit; padding:11px; border-radius:7px; border:1px solid #b8c2b9; } button { margin-top:16px; border:0; border-radius:7px; background:#e4f369; padding:12px 16px; color:#15231c; font:inherit; font-weight:700; cursor:pointer; } .notice { background:#dff4e9; padding:12px; border-radius:8px; } @media (max-width:700px) { nav span { display:none; } .metrics,.forms { grid-template-columns:1fr; } } diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..a931ab0 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "include": ["src"] } diff --git a/docs/plans/email-marketing-automation.md b/docs/plans/email-marketing-automation.md new file mode 100644 index 0000000..fc8153a --- /dev/null +++ b/docs/plans/email-marketing-automation.md @@ -0,0 +1,7 @@ +# Email marketing automation implementation order + +1. Configure MongoDB indexes and ClickHouse event storage. +2. Start the Axum API and connect TinyFlows webhook-triggered workflows. +3. Add Google OAuth state persistence and JWT authentication middleware. +4. Connect the Vite dashboard to authenticated campaign, contact, and analytics APIs. +5. Add a dedicated email delivery worker that filters unsubscribed contacts before provider delivery. diff --git a/docs/specs/email-marketing-automation.md b/docs/specs/email-marketing-automation.md new file mode 100644 index 0000000..36abb85 --- /dev/null +++ b/docs/specs/email-marketing-automation.md @@ -0,0 +1,7 @@ +# Email marketing automation + +The service manages contacts and campaign drafts in MongoDB. It records immutable lifecycle events in ClickHouse and sends the same lifecycle events to a TinyFlows webhook, where delivery and branching automation run. + +The public unsubscribe URL contains an opaque per-contact token. Submitting it makes the contact unsubscribed before analytics and flow notifications happen; email senders must query `subscribed = true` before delivery. + +Dashboard sign-in begins with Google OAuth and exchanges a successful authorization code for a 24-hour JWT. The deployment must persist and validate a high-entropy OAuth state value per browser session before exposing the callback. diff --git a/infra/clickhouse/init.sql b/infra/clickhouse/init.sql new file mode 100644 index 0000000..8bcafc1 --- /dev/null +++ b/infra/clickhouse/init.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS campaign_events ( + event_name String, + campaign_id String, + contact_id String, + occurred_at DateTime64(3, 'UTC') +) ENGINE = MergeTree +ORDER BY (event_name, occurred_at); From 16e551e6efb129d796028b8d09bebe23a7cb2d84 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 19:41:45 +0300 Subject: [PATCH 2/5] Harden marketing scaffold configuration Co-authored-by: Medulla --- Cargo.lock | 9 +- Cargo.toml | 2 + crates/marketing-server/Cargo.toml | 1 + crates/marketing-server/src/api.rs | 18 +- crates/marketing-server/src/auth.rs | 13 +- crates/marketing-server/src/config.rs | 25 +- crates/marketing-server/src/repository.rs | 16 +- crates/marketing-server/src/tinyflows.rs | 11 +- dashboard/package-lock.json | 1871 +++++++++++++-------- dashboard/package.json | 4 +- deny.toml | 1 + 11 files changed, 1277 insertions(+), 694 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81dd591..bcd6772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1307,6 +1307,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tower-http", "url", ] @@ -1857,9 +1858,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -1882,9 +1883,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index 20d50b9..0bd4fd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ mongodb = "3.2" chrono = { version = "0.4", features = ["serde"] } # HTTP client used by the TinyFlows webhook adapter. reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# CORS middleware allows the local Vite dashboard to call the Axum API. +tower-http = { version = "0.6", features = ["cors"] } # Native ClickHouse client used for immutable engagement analytics events. clickhouse = "0.13" # Signed API session tokens issued after a verified Google OAuth identity. diff --git a/crates/marketing-server/Cargo.toml b/crates/marketing-server/Cargo.toml index bf6d490..21687e8 100644 --- a/crates/marketing-server/Cargo.toml +++ b/crates/marketing-server/Cargo.toml @@ -18,6 +18,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +tower-http = { workspace = true } url = { workspace = true } [lints] diff --git a/crates/marketing-server/src/api.rs b/crates/marketing-server/src/api.rs index 336264e..589c626 100644 --- a/crates/marketing-server/src/api.rs +++ b/crates/marketing-server/src/api.rs @@ -5,13 +5,14 @@ use std::{fmt, sync::Arc}; use axum::{ Json, Router, extract::{Path, Query, State}, - http::StatusCode, + http::{HeaderValue, Method, StatusCode}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, }; use chrono::Utc; use mongodb::Database; use serde::{Deserialize, Serialize}; +use tower_http::cors::{Any, CorsLayer}; use crate::{ AnalyticsWriter, GoogleOAuth, MarketingRepository, TinyFlowsClient, @@ -55,6 +56,7 @@ impl AppState { /// Creates the application router. pub fn router(state: AppState) -> Router { + let dashboard_origin = HeaderValue::from_static("http://localhost:5173"); Router::new() .route("/health", get(health)) .route("/api/contacts", post(create_contact)) @@ -66,6 +68,12 @@ pub fn router(state: AppState) -> Router { "/unsubscribe/{token}", get(unsubscribe_page).post(unsubscribe), ) + .layer( + CorsLayer::new() + .allow_origin(dashboard_origin) + .allow_methods([Method::GET, Method::POST]) + .allow_headers(Any), + ) .with_state(Arc::new(state)) } @@ -145,10 +153,10 @@ async fn google_callback( Ok(Json(state.auth.exchange_code(&query.code).await?)) } -async fn unsubscribe_page(Path(token): Path) -> Html { - Html(format!( - "Unsubscribe

Unsubscribe from email

You can stop future marketing email with one click.

" - )) +async fn unsubscribe_page() -> Html<&'static str> { + Html( + "Unsubscribe

Unsubscribe from email

Use the form in the message to stop marketing email.

", + ) } async fn unsubscribe( diff --git a/crates/marketing-server/src/auth.rs b/crates/marketing-server/src/auth.rs index 300ffa5..a8a1099 100644 --- a/crates/marketing-server/src/auth.rs +++ b/crates/marketing-server/src/auth.rs @@ -3,12 +3,13 @@ use chrono::{Duration, Utc}; use jsonwebtoken::{EncodingKey, Header, encode}; use serde::{Deserialize, Serialize}; +use std::fmt; use url::form_urlencoded; use crate::error::Result; /// Google OAuth client and JWT session issuer. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct GoogleOAuth { client_id: String, client_secret: String, @@ -16,6 +17,16 @@ pub struct GoogleOAuth { jwt_secret: String, } +impl fmt::Debug for GoogleOAuth { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GoogleOAuth") + .field("client_id", &self.client_id) + .field("redirect_url", &self.redirect_url) + .finish_non_exhaustive() + } +} + /// Response returned after a successful OAuth callback. #[derive(Debug, Serialize)] pub struct Session { diff --git a/crates/marketing-server/src/config.rs b/crates/marketing-server/src/config.rs index ce1793d..9715f2e 100644 --- a/crates/marketing-server/src/config.rs +++ b/crates/marketing-server/src/config.rs @@ -1,9 +1,11 @@ //! Environment-backed application configuration. +use std::fmt; + use crate::error::{Error, Result}; /// Configuration required to start the API. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Config { /// MongoDB connection URI. pub mongodb_uri: String, @@ -27,6 +29,18 @@ pub struct Config { pub jwt_secret: String, } +impl fmt::Debug for Config { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Config") + .field("mongodb_database", &self.mongodb_database) + .field("public_base_url", &self.public_base_url) + .field("clickhouse_database", &self.clickhouse_database) + .field("google_client_id", &self.google_client_id) + .finish_non_exhaustive() + } +} + impl Config { /// Reads configuration from environment variables. /// @@ -35,7 +49,14 @@ impl Config { /// Returns validation errors for missing required variables. pub fn from_env() -> Result { fn required(name: &str) -> Result { - std::env::var(name).map_err(|_| Error::Validation(format!("missing required {name}"))) + let value = std::env::var(name) + .map_err(|_| Error::Validation(format!("missing required {name}")))?; + if value.trim().is_empty() { + return Err(Error::Validation(format!( + "required {name} must not be empty" + ))); + } + Ok(value) } Ok(Self { mongodb_uri: required("MONGODB_URI")?, diff --git a/crates/marketing-server/src/repository.rs b/crates/marketing-server/src/repository.rs index 1bda697..f2c238e 100644 --- a/crates/marketing-server/src/repository.rs +++ b/crates/marketing-server/src/repository.rs @@ -12,6 +12,8 @@ use crate::{ models::{Campaign, CampaignStatus, Contact, CreateCampaign, CreateContact}, }; +const UNSUBSCRIBE_PLACEHOLDER: &str = "{{unsubscribe_url}}"; + /// MongoDB repository for contacts and campaigns. #[derive(Clone, Debug)] pub struct MarketingRepository { @@ -57,8 +59,13 @@ impl MarketingRepository { /// Returns validation or database errors. pub async fn create_contact(&self, input: CreateContact) -> Result { let email = input.email.trim().to_lowercase(); - if !email.contains('@') { - return Err(Error::Validation("email must contain @".into())); + let mut parts = email.split('@'); + if email.chars().any(char::is_whitespace) + || parts.next().is_none_or(str::is_empty) + || parts.next().is_none_or(str::is_empty) + || parts.next().is_some() + { + return Err(Error::Validation("email address is invalid".into())); } let now = Utc::now(); let mut contact = Contact { @@ -96,6 +103,11 @@ impl MarketingRepository { "campaign name and subject are required".into(), )); } + if !input.html_body.contains(UNSUBSCRIBE_PLACEHOLDER) { + return Err(Error::Validation(format!( + "campaign HTML must include {UNSUBSCRIBE_PLACEHOLDER}" + ))); + } let mut campaign = Campaign { id: None, name: input.name, diff --git a/crates/marketing-server/src/tinyflows.rs b/crates/marketing-server/src/tinyflows.rs index 00c5e92..43fa14b 100644 --- a/crates/marketing-server/src/tinyflows.rs +++ b/crates/marketing-server/src/tinyflows.rs @@ -1,16 +1,25 @@ //! `TinyFlows` webhook integration. use serde::Serialize; +use std::fmt; use crate::error::Result; /// Sends marketing lifecycle events to a `TinyFlows` webhook-triggered flow. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct TinyFlowsClient { webhook_url: String, http: reqwest::Client, } +impl fmt::Debug for TinyFlowsClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TinyFlowsClient") + .finish_non_exhaustive() + } +} + #[derive(Debug, Serialize)] struct WebhookEnvelope<'a, T: Serialize> { workflow: &'a str, diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 88bd73b..53d0ef2 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,30 +8,300 @@ "name": "marketing-dashboard", "version": "0.1.0", "dependencies": { - "@vitejs/plugin-react": "latest", - "react": "latest", - "react-dom": "latest", - "vite": "latest" + "@vitejs/plugin-react": "5.1.1", + "react": "19.2.4", + "react-dom": "19.2.4", + "vite": "7.3.5" }, "devDependencies": { - "@types/react": "latest", - "@types/react-dom": "latest", - "typescript": "latest" + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" } }, - "node_modules/@oxc-project/types": { - "version": "0.150.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", - "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, "funding": { - "url": "https://github.com/sponsors/oxc-project" + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", - "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -41,13 +311,13 @@ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", - "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -57,13 +327,29 @@ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", - "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -73,13 +359,13 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", - "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -89,15 +375,15 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", - "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, @@ -105,34 +391,31 @@ "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", - "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ - "arm" + "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", - "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ - "arm64" - ], - "libc": [ - "glibc" + "arm" ], "license": "MIT", "optional": true, @@ -140,37 +423,31 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", - "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", - "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" + "ia32" ], "license": "MIT", "optional": true, @@ -178,18 +455,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", - "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ - "s390x" - ], - "libc": [ - "glibc" + "loong64" ], "license": "MIT", "optional": true, @@ -197,18 +471,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", - "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ - "x64" - ], - "libc": [ - "glibc" + "mips64el" ], "license": "MIT", "optional": true, @@ -216,18 +487,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", - "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ - "x64" - ], - "libc": [ - "musl" + "ppc64" ], "license": "MIT", "optional": true, @@ -235,451 +503,792 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", - "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ - "arm64" + "riscv64" ], "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", - "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ - "arm64" + "s390x" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", - "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "node": ">=18" } }, - "node_modules/@types/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peerDependencies": { - "@types/react": "^19.3.0" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ - "ppc64" + "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "aix" + "netbsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "openharmony" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "sunos" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ - "arm" + "arm64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ - "arm64" + "ia32" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ - "loong64" + "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", "cpu": [ - "mips64el" + "x64" ], - "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", "cpu": [ - "ppc64" + "arm" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } + "android" + ] }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", "cpu": [ - "riscv64" + "arm64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">=16.20.0" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", "cpu": [ - "s390x" + "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">=16.20.0" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" ], - "engines": { - "node": ">=16.20.0" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">=16.20.0" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", "cpu": [ - "x64" + "loong64" ], - "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" ], - "engines": { - "node": ">=16.20.0" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", "cpu": [ - "arm64" + "ppc64" ], - "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" ], - "engines": { - "node": ">=16.20.0" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" ], - "engines": { - "node": ">=16.20.0" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "sunos" + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">=16.20.0" - } + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" ], - "engines": { - "node": ">=16.20.0" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, "engines": { - "node": ">=16.20.0" + "node": ">=6.0.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", - "vite": "^8.0.0" + "bin": { + "browserslist": "cli.js" }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" }, - "babel-plugin-react-compiler": { - "optional": true + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, - "oxc-transform-react": { - "optional": true + { + "type": "github", + "url": "https://github.com/sponsors/ai" } - } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", @@ -688,13 +1297,77 @@ "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/fdir": { @@ -728,266 +1401,59 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6.9.0" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.19", @@ -1007,6 +1473,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1054,65 +1529,95 @@ } }, "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.28.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.3.0" + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/rolldown": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", - "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.150.0", - "@rolldown/pluginutils": "^1.0.0" + "@types/estree": "1.0.9" }, "bin": { - "rolldown": "bin/cli.mjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.9", - "@rolldown/binding-android-arm64": "1.2.9", - "@rolldown/binding-darwin-arm64": "1.2.9", - "@rolldown/binding-darwin-x64": "1.2.9", - "@rolldown/binding-freebsd-x64": "1.2.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", - "@rolldown/binding-linux-arm64-gnu": "1.2.9", - "@rolldown/binding-linux-arm64-musl": "1.2.9", - "@rolldown/binding-linux-ppc64-gnu": "1.2.9", - "@rolldown/binding-linux-s390x-gnu": "1.2.9", - "@rolldown/binding-linux-x64-gnu": "1.2.9", - "@rolldown/binding-linux-x64-musl": "1.2.9", - "@rolldown/binding-openharmony-arm64": "1.2.9", - "@rolldown/binding-win32-arm64-msvc": "1.2.9", - "@rolldown/binding-win32-x64-msvc": "1.2.9" + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" } }, "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1139,51 +1644,61 @@ } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.6", - "tinyglobby": "^0.2.17" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -1199,10 +1714,9 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", + "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -1215,18 +1729,15 @@ "@types/node": { "optional": true }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, "jiti": { "optional": true }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -1249,6 +1760,12 @@ "optional": true } } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" } } } diff --git a/dashboard/package.json b/dashboard/package.json index 64dcc6d..b828a47 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" }, - "dependencies": { "@vitejs/plugin-react": "latest", "vite": "latest", "react": "latest", "react-dom": "latest" }, - "devDependencies": { "typescript": "latest", "@types/react": "latest", "@types/react-dom": "latest" } + "dependencies": { "@vitejs/plugin-react": "5.1.1", "vite": "7.3.5", "react": "19.2.4", "react-dom": "19.2.4" }, + "devDependencies": { "typescript": "5.9.3", "@types/react": "19.2.14", "@types/react-dom": "19.2.3" } } diff --git a/deny.toml b/deny.toml index 1134b6a..9269efd 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,7 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "CDLA-Permissive-2.0", + "CC0-1.0", "GPL-3.0-only", "ISC", "MIT", From 6e821aa55d2d4a1721429a558fd6430932a18242 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 21:55:28 +0300 Subject: [PATCH 3/5] Raise marketing server coverage and harden API auth Co-authored-by: Medulla --- Cargo.lock | 815 +++++++++++++++++- Cargo.toml | 2 + crates/marketing-server/Cargo.toml | 8 + crates/marketing-server/src/analytics.rs | 3 + crates/marketing-server/src/analytics/test.rs | 57 ++ crates/marketing-server/src/api.rs | 137 ++- crates/marketing-server/src/api/test.rs | 366 ++++++++ crates/marketing-server/src/auth.rs | 132 ++- crates/marketing-server/src/auth/test.rs | 397 ++++++++- crates/marketing-server/src/config.rs | 48 +- crates/marketing-server/src/config/test.rs | 155 ++++ .../src/{error.rs => error/mod.rs} | 47 +- crates/marketing-server/src/error/test.rs | 45 + crates/marketing-server/src/main.rs | 138 ++- crates/marketing-server/src/models.rs | 173 +++- crates/marketing-server/src/repository.rs | 390 +++++++-- crates/marketing-server/src/tinyflows.rs | 3 + crates/marketing-server/src/tinyflows/test.rs | 131 +++ 18 files changed, 2871 insertions(+), 176 deletions(-) create mode 100644 crates/marketing-server/src/analytics/test.rs create mode 100644 crates/marketing-server/src/api/test.rs create mode 100644 crates/marketing-server/src/config/test.rs rename crates/marketing-server/src/{error.rs => error/mod.rs} (55%) create mode 100644 crates/marketing-server/src/error/test.rs create mode 100644 crates/marketing-server/src/tinyflows/test.rs diff --git a/Cargo.lock b/Cargo.lock index bcd6772..a1bfaa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -30,6 +39,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.4.2" @@ -39,6 +54,44 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -126,6 +179,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -153,6 +212,88 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bollard" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.1", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.10.3", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bson" version = "2.15.0" @@ -165,7 +306,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "hex", - "indexmap", + "indexmap 2.14.0", "js-sys", "once_cell", "rand 0.9.5", @@ -348,6 +489,16 @@ dependencies = [ "libc", ] +[[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" @@ -466,11 +617,45 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive-syn-parse" @@ -551,6 +736,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.18.0" @@ -573,12 +775,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.3", + "web-time", +] + [[package]] name = "filetime" version = "0.2.29" @@ -605,6 +828,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -749,6 +978,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.17.1" @@ -840,6 +1094,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.5.0" @@ -904,6 +1167,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -915,6 +1179,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -931,6 +1209,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -954,6 +1245,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1088,6 +1394,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1095,7 +1412,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1120,12 +1439,75 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -1306,9 +1688,13 @@ dependencies = [ "reqwest", "serde", "serde_json", + "testcontainers", + "thiserror 2.0.20", "tokio", + "tower", "tower-http", "url", + "uuid", ] [[package]] @@ -1402,7 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af84443ae9878f59a6818686f339064479f42cdf293c4c637d8fc9c0a8c5eed6" dependencies = [ "base64 0.22.1", - "bitflags", + "bitflags 2.13.1", "bson", "derive-where", "derive_more", @@ -1459,6 +1845,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -1469,6 +1869,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1484,6 +1893,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1503,6 +1933,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1526,6 +1962,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-display" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78deb158fb1d73b29efb4b7e9b9860b78059c670de06bd28df8d0b458ded0eb" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e95a50d1084dab562913062c4c34bb204b68fc6ec38a1395909ff5aaaf4f10a" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -1551,6 +2012,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1563,6 +2044,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1607,6 +2097,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "quinn" version = "0.11.12" @@ -1751,9 +2273,58 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "replace_with" version = "0.1.8" @@ -1849,7 +2420,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1871,6 +2442,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1913,6 +2496,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1930,6 +2546,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "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" @@ -1993,7 +2632,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2012,6 +2651,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2039,8 +2689,19 @@ version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", "serde_core", + "serde_json", "serde_with_macros", + "time", ] [[package]] @@ -2178,6 +2839,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2232,8 +2916,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.13.1", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2309,6 +2993,37 @@ dependencies = [ "serde_json", ] +[[package]] +name = "testcontainers" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2bbe381afaaa58ea610c5fc3ffb2184063a32b3e358a179f0b4865dd59934a" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2481,6 +3196,17 @@ dependencies = [ "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.19" @@ -2492,6 +3218,7 @@ dependencies = [ "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -2523,7 +3250,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -2537,6 +3264,46 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -2545,9 +3312,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2559,7 +3329,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -2731,6 +3501,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2887,6 +3658,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2896,6 +3683,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3202,7 +3995,7 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", "thiserror 2.0.20", "zopfli", diff --git a/Cargo.toml b/Cargo.toml index 0bd4fd2..65deb55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ clickhouse = "0.13" jsonwebtoken = "9" # URL encoding for Google OAuth authorization URLs. url = "2" +# Cryptographically random opaque unsubscribe and OAuth state tokens. +uuid = { version = "1", features = ["v4"] } # Module integration tests exercise the real asynchronous in-memory TinyBus. # Lints apply to every member that opts in with `[lints] workspace = true`, and diff --git a/crates/marketing-server/Cargo.toml b/crates/marketing-server/Cargo.toml index 21687e8..3bdf6f3 100644 --- a/crates/marketing-server/Cargo.toml +++ b/crates/marketing-server/Cargo.toml @@ -18,8 +18,16 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +thiserror = { workspace = true } tower-http = { workspace = true } url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +# Spins up an isolated pinned MongoDB instance for repository contract tests. +testcontainers = "0.28" +# Drives Axum routers directly in deterministic in-process API tests. +tower = "0.5" [lints] workspace = true diff --git a/crates/marketing-server/src/analytics.rs b/crates/marketing-server/src/analytics.rs index 7caf6d2..d9eb07c 100644 --- a/crates/marketing-server/src/analytics.rs +++ b/crates/marketing-server/src/analytics.rs @@ -68,3 +68,6 @@ impl fmt::Debug for AnalyticsWriter { .finish_non_exhaustive() } } + +#[cfg(test)] +mod test; diff --git a/crates/marketing-server/src/analytics/test.rs b/crates/marketing-server/src/analytics/test.rs new file mode 100644 index 0000000..b726fe0 --- /dev/null +++ b/crates/marketing-server/src/analytics/test.rs @@ -0,0 +1,57 @@ +//! Tests for immutable campaign analytics payloads. + +use chrono::{DateTime, Utc}; +use serde_json::json; + +use super::{AnalyticsWriter, CampaignEvent}; + +#[test] +fn campaign_event_serializes_the_clickhouse_column_names() -> Result<(), Box> +{ + let occurred_at = DateTime::parse_from_rfc3339("2025-02-03T04:05:06Z")?.with_timezone(&Utc); + let event = CampaignEvent { + event_name: "email_opened".into(), + campaign_id: "campaign-42".into(), + contact_id: "contact-7".into(), + occurred_at, + }; + + assert_eq!( + serde_json::to_value(event)?, + json!({ + "event_name": "email_opened", + "campaign_id": "campaign-42", + "contact_id": "contact-7", + "occurred_at": "2025-02-03T04:05:06Z", + }) + ); + Ok(()) +} + +#[test] +fn analytics_writer_debug_output_redacts_connection_details() { + let writer = AnalyticsWriter::new( + "https://analytics-user:secret@clickhouse.example.test", + "private_campaign_data", + ); + + let debug = format!("{writer:?}"); + + assert!(debug.contains("AnalyticsWriter")); + assert!(!debug.contains("analytics-user")); + assert!(!debug.contains("secret")); + assert!(!debug.contains("private_campaign_data")); +} + +#[tokio::test] +async fn record_returns_a_validation_error_when_clickhouse_is_unreachable() { + let writer = AnalyticsWriter::new("http://127.0.0.1:1", "analytics"); + let event = CampaignEvent { + event_name: "email_opened".into(), + campaign_id: "campaign-42".into(), + contact_id: "contact-7".into(), + occurred_at: Utc::now(), + }; + + assert!(writer.record(&event).await.is_err()); +} diff --git a/crates/marketing-server/src/api.rs b/crates/marketing-server/src/api.rs index 589c626..ce185ff 100644 --- a/crates/marketing-server/src/api.rs +++ b/crates/marketing-server/src/api.rs @@ -4,9 +4,10 @@ use std::{fmt, sync::Arc}; use axum::{ Json, Router, - extract::{Path, Query, State}, - http::{HeaderValue, Method, StatusCode}, - response::{Html, IntoResponse, Redirect}, + extract::{Path, Query, Request, State}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, + middleware::{self, Next}, + response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; use chrono::Utc; @@ -57,24 +58,47 @@ impl AppState { /// Creates the application router. pub fn router(state: AppState) -> Router { let dashboard_origin = HeaderValue::from_static("http://localhost:5173"); - Router::new() - .route("/health", get(health)) + let state = Arc::new(state); + let protected_api = Router::new() .route("/api/contacts", post(create_contact)) .route("/api/campaigns", post(create_campaign)) .route("/api/campaigns/{id}/launch", post(launch_campaign)) + .route_layer(middleware::from_fn_with_state( + Arc::clone(&state), + require_dashboard_session, + )); + Router::new() + .route("/health", get(health)) .route("/api/auth/google", get(google_login)) .route("/api/auth/google/callback", get(google_callback)) .route( "/unsubscribe/{token}", get(unsubscribe_page).post(unsubscribe), ) + .merge(protected_api) .layer( CorsLayer::new() .allow_origin(dashboard_origin) .allow_methods([Method::GET, Method::POST]) .allow_headers(Any), ) - .with_state(Arc::new(state)) + .with_state(state) +} + +async fn require_dashboard_session( + State(state): State>, + request: Request, + next: Next, +) -> Response { + let token = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + if token.is_none_or(|token| state.auth.validate_dashboard_token(token).is_err()) { + return StatusCode::UNAUTHORIZED.into_response(); + } + next.run(request).await } async fn health() -> StatusCode { @@ -108,33 +132,28 @@ async fn launch_campaign( State(state): State>, Path(id): Path, ) -> Result { - let campaign = state.repository.launch_campaign(&id).await?; - state - .workflows - .trigger("campaign.launched", &campaign) - .await?; - state - .analytics - .record(&CampaignEvent { - event_name: "campaign_launched".into(), - campaign_id: id, - contact_id: String::new(), - occurred_at: Utc::now(), - }) - .await?; - Ok(Json(campaign)) -} - -#[derive(Debug, Deserialize)] -struct LoginQuery { - state: String, + let outcome = state.repository.launch_campaign(&id).await?; + if outcome.newly_launched { + state + .workflows + .trigger("campaign.launched", &outcome.campaign) + .await?; + state + .analytics + .record(&CampaignEvent { + event_name: "campaign_launched".into(), + campaign_id: id, + contact_id: String::new(), + occurred_at: Utc::now(), + }) + .await?; + } + Ok(Json(outcome.campaign)) } -async fn google_login( - State(state): State>, - Query(query): Query, -) -> Redirect { - Redirect::temporary(&state.auth.authorization_url(&query.state)) +async fn google_login(State(state): State>) -> Result { + let oauth_state = state.auth.generate_state()?; + login_response(&state.auth, &oauth_state) } #[derive(Debug, Deserialize)] @@ -145,12 +164,57 @@ struct CallbackQuery { async fn google_callback( State(state): State>, + headers: HeaderMap, Query(query): Query, -) -> Result> { - if query.state.trim().is_empty() { - return Err(crate::Error::Validation("OAuth state is required".into())); +) -> Result { + if !callback_state_is_valid(&state.auth, &headers, &query.state) { + return Err(crate::Error::Validation("OAuth state is invalid".into())); } - Ok(Json(state.auth.exchange_code(&query.code).await?)) + let session = state.auth.exchange_code(&query.code).await?; + Ok(( + [(header::SET_COOKIE, clear_oauth_state_cookie())], + Json(session), + )) +} + +fn login_response(auth: &GoogleOAuth, oauth_state: &str) -> Result { + Ok(( + [(header::SET_COOKIE, oauth_state_cookie(oauth_state)?)], + Redirect::temporary(&auth.authorization_url(oauth_state)), + ) + .into_response()) +} + +fn oauth_state_cookie(oauth_state: &str) -> Result { + HeaderValue::from_str(&format!( + "oauth_state={oauth_state}; Path=/api/auth/google; Max-Age=600; HttpOnly; Secure; SameSite=Lax" + )) + .map_err(|error| crate::Error::Validation(error.to_string())) +} + +fn clear_oauth_state_cookie() -> HeaderValue { + HeaderValue::from_static( + "oauth_state=; Path=/api/auth/google; Max-Age=0; HttpOnly; Secure; SameSite=Lax", + ) +} + +fn callback_state_is_valid(auth: &GoogleOAuth, headers: &HeaderMap, state: &str) -> bool { + cookie_value(headers, "oauth_state").is_some_and(|value| value == state) + && auth.is_valid_state(state) +} + +fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .map(str::trim) + .find_map(|part| { + part.split_once('=') + .filter(|(key, _)| *key == name) + .map(|(_, value)| value) + }) } async fn unsubscribe_page() -> Html<&'static str> { @@ -210,3 +274,6 @@ impl ContactResponse { } } } + +#[cfg(test)] +mod test; diff --git a/crates/marketing-server/src/api/test.rs b/crates/marketing-server/src/api/test.rs new file mode 100644 index 0000000..825962b --- /dev/null +++ b/crates/marketing-server/src/api/test.rs @@ -0,0 +1,366 @@ +//! Deterministic unit tests for public-route helpers and response shaping. + +use axum::{ + body::{Body, to_bytes}, + http::{HeaderMap, HeaderValue, Request, StatusCode, header}, + response::IntoResponse, +}; +use chrono::{TimeZone, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use mongodb::Client; +use serde::Serialize; +use testcontainers::{ + GenericImage, + core::{IntoContainerPort, WaitFor}, + runners::AsyncRunner, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; +use tower::ServiceExt; + +use super::{ + ContactResponse, callback_state_is_valid, clear_oauth_state_cookie, cookie_value, health, + login_response, oauth_state_cookie, unsubscribe_page, +}; +use crate::{AnalyticsWriter, AppState, GoogleOAuth, TinyFlowsClient, models::Contact, router}; + +fn auth() -> GoogleOAuth { + GoogleOAuth::new( + "client-id".into(), + "client-secret".into(), + "https://dashboard.example.test/callback".into(), + "01234567890123456789012345678901".into(), + ) +} + +#[test] +fn extracts_named_cookie_without_matching_prefixes() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_static("theme=dark; oauth_state=issued; oauth_state_backup=ignored"), + ); + + assert_eq!(cookie_value(&headers, "oauth_state"), Some("issued")); + assert_eq!(cookie_value(&headers, "missing"), None); +} + +#[test] +fn ignores_missing_cookie_headers() { + assert_eq!(cookie_value(&HeaderMap::new(), "oauth_state"), None); +} + +#[test] +fn produces_a_secure_short_lived_oauth_cookie() -> Result<(), Box> { + let cookie = oauth_state_cookie("signed-state")?; + assert_eq!( + cookie.to_str()?, + "oauth_state=signed-state; Path=/api/auth/google; Max-Age=600; HttpOnly; Secure; SameSite=Lax" + ); + assert_eq!( + clear_oauth_state_cookie().to_str()?, + "oauth_state=; Path=/api/auth/google; Max-Age=0; HttpOnly; Secure; SameSite=Lax" + ); + Ok(()) +} + +#[test] +fn rejects_cookie_values_that_cannot_be_used_in_an_http_header() { + assert!(oauth_state_cookie("contains\nnewline").is_err()); +} + +#[test] +fn requires_a_matching_signed_state_bound_to_the_browser() -> Result<(), Box> +{ + let auth = auth(); + let state = auth.generate_state()?; + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("oauth_state={state}"))?, + ); + + assert!(callback_state_is_valid(&auth, &headers, &state)); + assert!(!callback_state_is_valid(&auth, &headers, "forged")); + + let mut mismatched = HeaderMap::new(); + mismatched.insert( + header::COOKIE, + HeaderValue::from_static("oauth_state=other"), + ); + assert!(!callback_state_is_valid(&auth, &mismatched, &state)); + Ok(()) +} + +#[test] +fn login_response_redirects_to_google_and_sets_the_bound_state_cookie() +-> Result<(), Box> { + let auth = auth(); + let response = login_response(&auth, "signed-state")?; + + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response + .headers() + .get(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()), + Some( + "oauth_state=signed-state; Path=/api/auth/google; Max-Age=600; HttpOnly; Secure; SameSite=Lax" + ) + ); + let location = response + .headers() + .get(header::LOCATION) + .ok_or("redirect has location")?; + assert!( + location + .to_str()? + .starts_with("https://accounts.google.com/o/oauth2/v2/auth?") + ); + Ok(()) +} + +#[tokio::test] +async fn health_route_is_empty_and_unsubscribe_page_is_static() +-> Result<(), Box> { + assert_eq!(health().await, StatusCode::NO_CONTENT); + let response = unsubscribe_page().await.into_response(); + let body = to_bytes(response.into_body(), usize::MAX).await?; + assert_eq!( + std::str::from_utf8(&body)?, + "Unsubscribe

Unsubscribe from email

Use the form in the message to stop marketing email.

" + ); + Ok(()) +} + +#[test] +fn contact_response_uses_a_single_normalized_public_base_url() +-> Result<(), Box> { + let created_at = Utc + .with_ymd_and_hms(2026, 9, 20, 12, 0, 0) + .single() + .ok_or("fixed timestamp is valid")?; + let contact = Contact { + id: None, + email: "ada@example.com".into(), + first_name: Some("Ada".into()), + subscribed: true, + unsubscribe_token: "opaque-token".into(), + created_at, + updated_at: created_at, + }; + + let response = ContactResponse::from_contact(&contact, "https://app.example.test///"); + assert_eq!(response.email, "ada@example.com"); + assert!(response.subscribed); + assert_eq!( + response.unsubscribe_url, + "https://app.example.test/unsubscribe/opaque-token" + ); + Ok(()) +} + +#[derive(Serialize)] +struct TestClaims<'a> { + sub: &'a str, + email: &'a str, + exp: i64, + iat: i64, +} + +fn dashboard_token() -> Result { + let now = Utc::now().timestamp(); + encode( + &Header::new(Algorithm::HS256), + &TestClaims { + sub: "google-user", + email: "ada@example.com", + iat: now, + exp: now + 3600, + }, + &EncodingKey::from_secret(b"01234567890123456789012345678901"), + ) +} + +async fn start_http_server(requests: usize) -> Result> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + tokio::spawn(async move { + for _ in 0..requests { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut request = [0_u8; 8192]; + if stream.read(&mut request).await.is_ok() { + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await; + } + } + }); + Ok(format!("http://{address}")) +} + +async fn asserts_public_routes(app: axum::Router) -> Result<(), Box> { + let health = app + .clone() + .oneshot(Request::builder().uri("/health").body(Body::empty())?) + .await?; + assert_eq!(health.status(), StatusCode::NO_CONTENT); + + let login = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/google") + .body(Body::empty())?, + ) + .await?; + assert_eq!(login.status(), StatusCode::TEMPORARY_REDIRECT); + let set_cookie = login + .headers() + .get(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .ok_or("login state cookie")?; + let state = set_cookie + .strip_prefix("oauth_state=") + .and_then(|value| value.split(';').next()) + .ok_or("login state value")?; + let callback = app + .oneshot( + Request::builder() + .uri(format!( + "/api/auth/google/callback?code=unused&state={state}" + )) + .header(header::COOKIE, "oauth_state=not-the-issued-state") + .body(Body::empty())?, + ) + .await?; + assert_eq!(callback.status(), StatusCode::BAD_REQUEST); + Ok(()) +} + +async fn creates_contact( + app: &axum::Router, + authorization: &str, +) -> Result> { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/contacts") + .header(header::AUTHORIZATION, authorization) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"email":"ada@example.com","first_name":"Ada"}"#, + ))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let body = to_bytes(response.into_body(), usize::MAX).await?; + let contact: serde_json::Value = serde_json::from_slice(&body)?; + let unsubscribe_url = contact["unsubscribe_url"] + .as_str() + .ok_or("contact has unsubscribe URL")?; + Ok(unsubscribe_url + .rsplit('/') + .next() + .ok_or("unsubscribe URL has token")? + .to_owned()) +} + +async fn creates_and_launches_campaign( + app: &axum::Router, + authorization: &str, +) -> Result<(), Box> { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/campaigns") + .header(header::AUTHORIZATION, authorization) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"name":"Launch","subject":"News","html_body":"{{unsubscribe_url}}"}"#, + ))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let campaign: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; + let id = campaign["_id"]["$oid"] + .as_str() + .ok_or("campaign has Mongo extended JSON id")?; + for expected_status in [StatusCode::OK, StatusCode::OK] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/campaigns/{id}/launch")) + .header(header::AUTHORIZATION, authorization) + .body(Body::empty())?, + ) + .await?; + assert_eq!(response.status(), expected_status); + } + Ok(()) +} + +#[tokio::test] +async fn router_authenticates_mutations_and_drives_the_campaign_lifecycle() +-> Result<(), Box> { + let mongo = GenericImage::new("mongo", "8.0.0") + .with_exposed_port(27017.tcp()) + .with_wait_for(WaitFor::message_on_either_std("Waiting for connections")) + .start() + .await?; + let port = mongo.get_host_port_ipv4(27017.tcp()).await?; + let database = Client::with_uri_str(format!("mongodb://127.0.0.1:{port}")) + .await? + .database("api_contract"); + let http_url = start_http_server(5).await?; + let state = AppState::new( + database, + TinyFlowsClient::new(http_url.clone()), + AnalyticsWriter::new(&http_url, "analytics"), + auth(), + "https://dashboard.example.test/".into(), + ) + .await?; + let app = router(state); + asserts_public_routes(app.clone()).await?; + + let unauthenticated = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/campaigns") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"name":"Launch","subject":"News","html_body":"{{unsubscribe_url}}"}"#, + ))?, + ) + .await?; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let authorization = format!("Bearer {}", dashboard_token()?); + let token = creates_contact(&app, &authorization).await?; + creates_and_launches_campaign(&app, &authorization).await?; + + let unsubscribe_response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/unsubscribe/{token}")) + .body(Body::empty())?, + ) + .await?; + assert_eq!(unsubscribe_response.status(), StatusCode::OK); + Ok(()) +} diff --git a/crates/marketing-server/src/auth.rs b/crates/marketing-server/src/auth.rs index a8a1099..670599e 100644 --- a/crates/marketing-server/src/auth.rs +++ b/crates/marketing-server/src/auth.rs @@ -1,13 +1,22 @@ //! Google OAuth authorization-code exchange and dashboard JWT issuance. use chrono::{Duration, Utc}; -use jsonwebtoken::{EncodingKey, Header, encode}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; -use std::fmt; +use std::{ + fmt, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; use url::form_urlencoded; use crate::error::Result; +const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL: &str = "https://openidconnect.googleapis.com/v1/userinfo"; + /// Google OAuth client and JWT session issuer. #[derive(Clone)] pub struct GoogleOAuth { @@ -15,6 +24,9 @@ pub struct GoogleOAuth { client_secret: String, redirect_url: String, jwt_secret: String, + token_url: String, + userinfo_url: String, + next_state_nonce: Arc, } impl fmt::Debug for GoogleOAuth { @@ -46,7 +58,7 @@ struct GoogleUser { email: String, email_verified: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Deserialize, Serialize)] struct Claims { sub: String, email: String, @@ -54,6 +66,13 @@ struct Claims { iat: i64, } +#[derive(Debug, Deserialize, Serialize)] +struct StateClaims { + exp: i64, + iat: i64, + nonce: u64, +} + impl GoogleOAuth { /// Creates an OAuth helper from configured credentials. #[must_use] @@ -68,9 +87,78 @@ impl GoogleOAuth { client_secret, redirect_url, jwt_secret, + token_url: GOOGLE_TOKEN_URL.into(), + userinfo_url: GOOGLE_USERINFO_URL.into(), + next_state_nonce: Arc::new(AtomicU64::new(0)), } } + #[cfg(test)] + fn with_endpoints(mut self, token_url: String, userinfo_url: String) -> Self { + self.token_url = token_url; + self.userinfo_url = userinfo_url; + self + } + + /// Creates a signed, short-lived OAuth state value for one login attempt. + /// + /// The caller must bind the returned value to the initiating browser (for + /// example, in a `Secure`, `HttpOnly`, `SameSite=Lax` cookie), then require + /// an exact match when processing the callback. + /// + /// # Errors + /// + /// Returns an error if the state token cannot be signed. + pub fn generate_state(&self) -> Result { + let now = Utc::now(); + let claims = StateClaims { + iat: now.timestamp(), + exp: (now + Duration::minutes(10)).timestamp(), + nonce: self.next_state_nonce.fetch_add(1, Ordering::Relaxed), + }; + encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_bytes()), + ) + .map_err(|error| crate::error::Error::Validation(error.to_string())) + } + + /// Checks that an OAuth state was issued by this server and has not expired. + #[must_use] + pub fn is_valid_state(&self, state: &str) -> bool { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + decode::( + state, + &DecodingKey::from_secret(self.jwt_secret.as_bytes()), + &validation, + ) + .is_ok() + } + + /// Validates a dashboard session JWT for use by request middleware. + /// + /// Only unexpired tokens signed with this instance's secret using HS256 are + /// accepted. The method deliberately returns no claims because callers that + /// only authorize a request should not need to handle identity data. + /// + /// # Errors + /// + /// Returns a validation error when the token is malformed, expired, signed + /// by another key, or uses an unexpected algorithm. + pub fn validate_dashboard_token(&self, token: &str) -> Result<()> { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + decode::( + token, + &DecodingKey::from_secret(self.jwt_secret.as_bytes()), + &validation, + ) + .map(|_| ()) + .map_err(|_| crate::error::Error::Validation("invalid dashboard token".into())) + } + /// Builds the Google consent URL for a CSRF state token. #[must_use] pub fn authorization_url(&self, state: &str) -> String { @@ -92,9 +180,14 @@ impl GoogleOAuth { /// /// Returns an error if Google rejects the code or the JWT cannot be signed. pub async fn exchange_code(&self, code: &str) -> Result { + if code.trim().is_empty() { + return Err(crate::error::Error::Validation( + "OAuth authorization code is required".into(), + )); + } let http = reqwest::Client::new(); let token = http - .post("https://oauth2.googleapis.com/token") + .post(&self.token_url) .form(&[ ("code", code), ("client_id", self.client_id.as_str()), @@ -107,20 +200,34 @@ impl GoogleOAuth { .error_for_status()? .json::() .await?; + let access_token = validated_access_token(token)?; let user = http - .get("https://openidconnect.googleapis.com/v1/userinfo") - .bearer_auth(token.access_token) + .get(&self.userinfo_url) + .bearer_auth(access_token) .send() .await? .error_for_status()? .json::() .await?; + self.issue_session(user, Utc::now()) + } + + fn issue_session(&self, user: GoogleUser, now: chrono::DateTime) -> Result { if !user.email_verified { return Err(crate::error::Error::Validation( "Google account email is not verified".into(), )); } - let now = Utc::now(); + if user.sub.trim().is_empty() { + return Err(crate::error::Error::Validation( + "Google account identifier is required".into(), + )); + } + if user.email.trim().is_empty() { + return Err(crate::error::Error::Validation( + "Google account email is required".into(), + )); + } let claims = Claims { sub: user.sub, email: user.email.clone(), @@ -128,7 +235,7 @@ impl GoogleOAuth { exp: (now + Duration::hours(24)).timestamp(), }; let token = encode( - &Header::default(), + &Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(self.jwt_secret.as_bytes()), ) @@ -140,5 +247,14 @@ impl GoogleOAuth { } } +fn validated_access_token(token: TokenResponse) -> Result { + if token.access_token.trim().is_empty() { + return Err(crate::error::Error::Validation( + "Google did not return an access token".into(), + )); + } + Ok(token.access_token) +} + #[cfg(test)] mod test; diff --git a/crates/marketing-server/src/auth/test.rs b/crates/marketing-server/src/auth/test.rs index 370a5e7..12ebecd 100644 --- a/crates/marketing-server/src/auth/test.rs +++ b/crates/marketing-server/src/auth/test.rs @@ -1,20 +1,401 @@ -//! Tests for Google OAuth authorization URL construction. +//! Tests for Google OAuth authorization URLs, state, and session issuance. -use super::GoogleOAuth; +use chrono::{Duration, Utc}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use std::{ + fmt::Display, + io::{Error as IoError, ErrorKind, Read, Write}, + net::TcpListener, + thread, +}; -#[test] -fn authorization_url_contains_encoded_oauth_parameters() { - let oauth = GoogleOAuth::new( +use super::{Claims, GoogleOAuth, GoogleUser, StateClaims, TokenResponse, validated_access_token}; + +type TestResult = Result>; + +struct MockResponse { + status: u16, + body: &'static str, +} + +struct CapturedRequest { + request_line: String, + headers: String, + body: Vec, +} + +type OAuthServer = ( + String, + thread::JoinHandle>>, +); + +fn oauth_server(responses: Vec) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0")?; + let base_url = format!("http://{}", listener.local_addr()?); + let server = thread::spawn(move || -> std::io::Result> { + let mut requests = Vec::with_capacity(responses.len()); + for response in responses { + let (mut stream, _) = listener.accept()?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut buffer)?; + if read == 0 { + return Err(IoError::new( + ErrorKind::UnexpectedEof, + "request ended before headers", + )); + } + request.extend_from_slice(&buffer[..read]); + if let Some(position) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break position + 4; + } + }; + let headers = std::str::from_utf8(&request[..header_end]) + .map_err(IoError::other)? + .to_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }) + .unwrap_or("0") + .parse::() + .map_err(IoError::other)?; + while request.len() < header_end + content_length { + let read = stream.read(&mut buffer)?; + if read == 0 { + return Err(IoError::new( + ErrorKind::UnexpectedEof, + "request ended before body", + )); + } + request.extend_from_slice(&buffer[..read]); + } + stream.write_all( + format!( + "HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status, + response.body.len(), + response.body, + ) + .as_bytes(), + )?; + requests.push(CapturedRequest { + request_line: headers + .lines() + .next() + .ok_or_else(|| IoError::other("request line is present"))? + .into(), + headers, + body: request[header_end..header_end + content_length].to_vec(), + }); + } + Ok(requests) + }); + Ok((base_url, server)) +} + +fn mock_oauth(base_url: &str) -> GoogleOAuth { + oauth().with_endpoints(format!("{base_url}/token"), format!("{base_url}/userinfo")) +} + +fn error_text(result: Result) -> Option { + result.err().map(|error| error.to_string()) +} + +fn oauth() -> GoogleOAuth { + GoogleOAuth::new( "client id".into(), "secret".into(), "https://dashboard.example.test/callback".into(), - "jwt-secret".into(), - ); + "a sufficiently long JWT secret for deterministic tests".into(), + ) +} - let url = oauth.authorization_url("random state"); +#[test] +fn authorization_url_contains_encoded_oauth_parameters() { + let url = oauth().authorization_url("random state"); assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?")); assert!(url.contains("client_id=client+id")); assert!(url.contains("redirect_uri=https%3A%2F%2Fdashboard.example.test%2Fcallback")); + assert!(url.contains("response_type=code")); + assert!(url.contains("scope=openid+email+profile")); assert!(url.contains("state=random+state")); } + +#[test] +fn authorization_url_encodes_reserved_state_characters() { + let url = oauth().authorization_url("state&code=attacker-value"); + + assert!(url.contains("state=state%26code%3Dattacker-value")); + assert!(!url.contains("&code=attacker-value")); +} + +#[test] +fn debug_output_does_not_expose_secrets() { + let output = format!("{:?}", oauth()); + + assert!(output.contains("client id")); + assert!(!output.contains("jwt secret")); + assert!(!output.contains("secret\"")); +} + +#[test] +fn generated_state_is_valid_and_unique() -> TestResult { + let oauth = oauth(); + + let first = oauth.generate_state()?; + let second = oauth.generate_state()?; + + assert_ne!(first, second); + assert!(oauth.is_valid_state(&first)); + assert!(oauth.is_valid_state(&second)); + Ok(()) +} + +#[test] +fn state_signed_with_another_secret_is_rejected() -> TestResult { + let trusted = oauth(); + let untrusted = GoogleOAuth::new( + "client".into(), + "secret".into(), + "https://dashboard.example.test/callback".into(), + "another sufficiently long JWT secret for tests".into(), + ); + + let state = untrusted.generate_state()?; + + assert!(!trusted.is_valid_state(&state)); + assert!(!trusted.is_valid_state("not-a-jwt")); + Ok(()) +} + +#[test] +fn expired_state_is_rejected() -> TestResult { + let oauth = oauth(); + let expired = jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::HS256), + &StateClaims { + iat: (Utc::now() - Duration::minutes(20)).timestamp(), + exp: (Utc::now() - Duration::minutes(2)).timestamp(), + nonce: 0, + }, + &jsonwebtoken::EncodingKey::from_secret( + b"a sufficiently long JWT secret for deterministic tests", + ), + )?; + + assert!(!oauth.is_valid_state(&expired)); + Ok(()) +} + +#[test] +fn rejects_an_empty_google_access_token() { + let error = error_text(validated_access_token(TokenResponse { + access_token: " ".into(), + })); + + assert_eq!( + error.as_deref(), + Some("Google did not return an access token") + ); +} + +#[tokio::test] +async fn exchange_code_rejects_an_empty_code_before_network_access() { + let error = error_text(oauth().exchange_code(" \t").await); + + assert_eq!( + error.as_deref(), + Some("OAuth authorization code is required") + ); +} + +#[tokio::test] +async fn exchange_code_posts_the_code_then_uses_the_access_token() -> TestResult { + let (base_url, server) = oauth_server(vec![ + MockResponse { + status: 200, + body: r#"{"access_token":"access-token"}"#, + }, + MockResponse { + status: 200, + body: r#"{"sub":"google-id","email":"person@example.test","email_verified":true}"#, + }, + ])?; + + let session = mock_oauth(&base_url).exchange_code("code value").await?; + let requests = server + .join() + .map_err(|_| IoError::other("OAuth mock thread panicked"))??; + + assert_eq!(session.email, "person@example.test"); + assert!(requests[0].request_line.starts_with("POST /token HTTP/1.1")); + assert_eq!( + std::str::from_utf8(&requests[0].body)?, + "code=code+value&client_id=client+id&client_secret=secret&redirect_uri=https%3A%2F%2Fdashboard.example.test%2Fcallback&grant_type=authorization_code" + ); + assert!( + requests[1] + .request_line + .starts_with("GET /userinfo HTTP/1.1") + ); + assert!( + requests[1] + .headers + .lines() + .any(|line| line == "authorization: Bearer access-token") + ); + Ok(()) +} + +#[tokio::test] +async fn exchange_code_maps_a_rejected_token_response_to_a_workflow_error() -> TestResult { + let (base_url, server) = oauth_server(vec![MockResponse { + status: 400, + body: r#"{"error":"invalid_grant"}"#, + }])?; + + let result = mock_oauth(&base_url).exchange_code("rejected-code").await; + let _requests = server + .join() + .map_err(|_| IoError::other("OAuth mock thread panicked"))??; + + assert!(matches!(result, Err(crate::Error::Workflow(_)))); + Ok(()) +} + +#[tokio::test] +async fn exchange_code_rejects_an_unverified_google_user() -> TestResult { + let (base_url, server) = oauth_server(vec![ + MockResponse { + status: 200, + body: r#"{"access_token":"access-token"}"#, + }, + MockResponse { + status: 200, + body: r#"{"sub":"google-id","email":"person@example.test","email_verified":false}"#, + }, + ])?; + + let result = mock_oauth(&base_url).exchange_code("code").await; + let _requests = server + .join() + .map_err(|_| IoError::other("OAuth mock thread panicked"))??; + + assert_eq!( + error_text(result).as_deref(), + Some("Google account email is not verified") + ); + Ok(()) +} + +#[test] +fn issue_session_signs_a_24_hour_hs256_token() -> TestResult { + let oauth = oauth(); + let now = Utc::now(); + let session = oauth.issue_session( + GoogleUser { + sub: "google-user-id".into(), + email: "person@example.test".into(), + email_verified: true, + }, + now, + )?; + let claims = decode::( + &session.token, + &DecodingKey::from_secret(b"a sufficiently long JWT secret for deterministic tests"), + &Validation::new(Algorithm::HS256), + )? + .claims; + + assert_eq!(session.email, "person@example.test"); + assert_eq!(claims.sub, "google-user-id"); + assert_eq!(claims.email, "person@example.test"); + assert_eq!(claims.iat, now.timestamp()); + assert_eq!(claims.exp, (now + Duration::hours(24)).timestamp()); + Ok(()) +} + +#[test] +fn dashboard_token_validation_accepts_a_current_session() -> TestResult { + let oauth = oauth(); + let session = oauth.issue_session( + GoogleUser { + sub: "google-user-id".into(), + email: "person@example.test".into(), + email_verified: true, + }, + Utc::now(), + )?; + + oauth.validate_dashboard_token(&session.token)?; + Ok(()) +} + +#[test] +fn dashboard_token_validation_rejects_tampered_and_expired_tokens() -> TestResult { + let oauth = oauth(); + let tampered = oauth + .issue_session( + GoogleUser { + sub: "google-user-id".into(), + email: "person@example.test".into(), + email_verified: true, + }, + Utc::now(), + )? + .token; + let mut bytes = tampered.into_bytes(); + let last = bytes.len() - 1; + bytes[last] = if bytes[last] == b'a' { b'b' } else { b'a' }; + let tampered = String::from_utf8(bytes)?; + let expired = jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::HS256), + &Claims { + sub: "google-user-id".into(), + email: "person@example.test".into(), + iat: (Utc::now() - Duration::hours(2)).timestamp(), + exp: (Utc::now() - Duration::minutes(2)).timestamp(), + }, + &jsonwebtoken::EncodingKey::from_secret( + b"a sufficiently long JWT secret for deterministic tests", + ), + )?; + + for token in [&tampered, &expired] { + let error = error_text(oauth.validate_dashboard_token(token)); + assert_eq!(error.as_deref(), Some("invalid dashboard token")); + } + Ok(()) +} + +#[test] +fn issue_session_rejects_unverified_or_incomplete_google_identities() { + let oauth = oauth(); + let cases = [ + GoogleUser { + sub: "id".into(), + email: "person@example.test".into(), + email_verified: false, + }, + GoogleUser { + sub: " ".into(), + email: "person@example.test".into(), + email_verified: true, + }, + GoogleUser { + sub: "id".into(), + email: " ".into(), + email_verified: true, + }, + ]; + + for user in cases { + assert!(oauth.issue_session(user, Utc::now()).is_err()); + } +} diff --git a/crates/marketing-server/src/config.rs b/crates/marketing-server/src/config.rs index 9715f2e..858cb00 100644 --- a/crates/marketing-server/src/config.rs +++ b/crates/marketing-server/src/config.rs @@ -1,6 +1,7 @@ //! Environment-backed application configuration. use std::fmt; +use url::Url; use crate::error::{Error, Result}; @@ -48,28 +49,61 @@ impl Config { /// /// Returns validation errors for missing required variables. pub fn from_env() -> Result { - fn required(name: &str) -> Result { - let value = std::env::var(name) - .map_err(|_| Error::Validation(format!("missing required {name}")))?; + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { + let required = |name: &str| -> Result { + let value = lookup(name) + .ok_or_else(|| Error::Validation(format!("missing required {name}")))?; if value.trim().is_empty() { return Err(Error::Validation(format!( "required {name} must not be empty" ))); } Ok(value) + }; + let public_base_url = + lookup("PUBLIC_BASE_URL").unwrap_or_else(|| "http://localhost:3000".into()); + if public_base_url.trim().is_empty() { + return Err(Error::Validation( + "PUBLIC_BASE_URL must not be empty".into(), + )); + } + let google_redirect_url = required("GOOGLE_REDIRECT_URL")?; + validate_http_url("PUBLIC_BASE_URL", &public_base_url)?; + validate_http_url("GOOGLE_REDIRECT_URL", &google_redirect_url)?; + let jwt_secret = required("JWT_SECRET")?; + if jwt_secret.len() < 32 { + return Err(Error::Validation( + "JWT_SECRET must be at least 32 bytes".into(), + )); } Ok(Self { mongodb_uri: required("MONGODB_URI")?, mongodb_database: required("MONGODB_DATABASE")?, tinyflows_webhook_url: required("TINYFLOWS_WEBHOOK_URL")?, - public_base_url: std::env::var("PUBLIC_BASE_URL") - .unwrap_or_else(|_| "http://localhost:3000".into()), + public_base_url, clickhouse_url: required("CLICKHOUSE_URL")?, clickhouse_database: required("CLICKHOUSE_DATABASE")?, google_client_id: required("GOOGLE_CLIENT_ID")?, google_client_secret: required("GOOGLE_CLIENT_SECRET")?, - google_redirect_url: required("GOOGLE_REDIRECT_URL")?, - jwt_secret: required("JWT_SECRET")?, + google_redirect_url, + jwt_secret, }) } } + +fn validate_http_url(name: &str, value: &str) -> Result<()> { + let url = Url::parse(value) + .map_err(|_| Error::Validation(format!("{name} must be an absolute HTTP URL")))?; + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() { + return Err(Error::Validation(format!( + "{name} must be an absolute HTTP URL" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod test; diff --git a/crates/marketing-server/src/config/test.rs b/crates/marketing-server/src/config/test.rs new file mode 100644 index 0000000..41930a0 --- /dev/null +++ b/crates/marketing-server/src/config/test.rs @@ -0,0 +1,155 @@ +//! Tests for environment configuration validation without mutating process state. + +use std::collections::HashMap; +use std::fmt::Display; + +use super::{Config, validate_http_url}; + +type TestResult = Result<(), Box>; + +fn error_text(result: Result) -> Option { + result.err().map(|error| error.to_string()) +} + +fn valid_values() -> HashMap<&'static str, String> { + HashMap::from([ + ("MONGODB_URI", "mongodb://localhost:27017".into()), + ("MONGODB_DATABASE", "marketing".into()), + ( + "TINYFLOWS_WEBHOOK_URL", + "https://flows.example.test/hooks".into(), + ), + ("CLICKHOUSE_URL", "http://localhost:8123".into()), + ("CLICKHOUSE_DATABASE", "analytics".into()), + ("GOOGLE_CLIENT_ID", "client-id".into()), + ("GOOGLE_CLIENT_SECRET", "client-secret".into()), + ( + "GOOGLE_REDIRECT_URL", + "https://dashboard.example.test/callback".into(), + ), + ("JWT_SECRET", "at-least-thirty-two-bytes-long-secret".into()), + ]) +} + +#[test] +fn loads_required_values_and_default_public_url() -> TestResult { + let values = valid_values(); + + let config = Config::from_lookup(|name| values.get(name).cloned())?; + + assert_eq!(config.mongodb_database, "marketing"); + assert_eq!(config.public_base_url, "http://localhost:3000"); + assert_eq!( + config.google_redirect_url, + "https://dashboard.example.test/callback" + ); + Ok(()) +} + +#[test] +fn accepts_an_explicit_https_public_url() -> TestResult { + let mut values = valid_values(); + values.insert("PUBLIC_BASE_URL", "https://dashboard.example.test/".into()); + + let config = Config::from_lookup(|name| values.get(name).cloned())?; + + assert_eq!(config.public_base_url, "https://dashboard.example.test/"); + Ok(()) +} + +#[test] +fn rejects_missing_and_blank_required_values() { + let mut missing = valid_values(); + missing.remove("MONGODB_URI"); + let error = error_text(Config::from_lookup(|name| missing.get(name).cloned())); + assert_eq!(error.as_deref(), Some("missing required MONGODB_URI")); + + let mut blank = valid_values(); + blank.insert("GOOGLE_CLIENT_SECRET", " ".into()); + let error = error_text(Config::from_lookup(|name| blank.get(name).cloned())); + assert_eq!( + error.as_deref(), + Some("required GOOGLE_CLIENT_SECRET must not be empty") + ); +} + +#[test] +fn rejects_invalid_urls_and_weak_jwt_secrets() { + let mut invalid_url = valid_values(); + invalid_url.insert("GOOGLE_REDIRECT_URL", "not a URL".into()); + let error = error_text(Config::from_lookup(|name| invalid_url.get(name).cloned())); + assert_eq!( + error.as_deref(), + Some("GOOGLE_REDIRECT_URL must be an absolute HTTP URL") + ); + + let mut weak_secret = valid_values(); + weak_secret.insert("JWT_SECRET", "too-short".into()); + let error = error_text(Config::from_lookup(|name| weak_secret.get(name).cloned())); + assert_eq!( + error.as_deref(), + Some("JWT_SECRET must be at least 32 bytes") + ); +} + +#[test] +fn rejects_a_blank_or_non_http_public_url() { + let mut blank = valid_values(); + blank.insert("PUBLIC_BASE_URL", " ".into()); + let error = error_text(Config::from_lookup(|name| blank.get(name).cloned())); + assert_eq!(error.as_deref(), Some("PUBLIC_BASE_URL must not be empty")); + + let mut file_url = valid_values(); + file_url.insert("PUBLIC_BASE_URL", "file:///dashboard".into()); + let error = error_text(Config::from_lookup(|name| file_url.get(name).cloned())); + assert_eq!( + error.as_deref(), + Some("PUBLIC_BASE_URL must be an absolute HTTP URL") + ); +} + +#[test] +fn reports_each_required_setting_when_it_is_missing_or_blank() { + for name in [ + "MONGODB_URI", + "MONGODB_DATABASE", + "TINYFLOWS_WEBHOOK_URL", + "CLICKHOUSE_URL", + "CLICKHOUSE_DATABASE", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GOOGLE_REDIRECT_URL", + "JWT_SECRET", + ] { + let mut missing = valid_values(); + missing.remove(name); + let error = error_text(Config::from_lookup(|key| missing.get(key).cloned())); + let expected = format!("missing required {name}"); + assert_eq!(error.as_deref(), Some(expected.as_str())); + + let mut blank = valid_values(); + blank.insert(name, " \t".into()); + let error = error_text(Config::from_lookup(|key| blank.get(key).cloned())); + let expected = format!("required {name} must not be empty"); + assert_eq!(error.as_deref(), Some(expected.as_str())); + } +} + +#[test] +fn environment_lookup_has_the_same_validation_contract() { + let _result = Config::from_env(); +} + +#[test] +fn debug_output_redacts_secrets_and_urls_cover_both_invalid_forms() -> TestResult { + let values = valid_values(); + let config = Config::from_lookup(|name| values.get(name).cloned())?; + let debug = format!("{config:?}"); + assert!(debug.contains("Config")); + assert!(!debug.contains("client-secret")); + assert!(!debug.contains("JWT_SECRET")); + + assert!(validate_http_url("endpoint", "not a URL").is_err()); + assert!(validate_http_url("endpoint", "mailto:person@example.test").is_err()); + Ok(()) +} diff --git a/crates/marketing-server/src/error.rs b/crates/marketing-server/src/error/mod.rs similarity index 55% rename from crates/marketing-server/src/error.rs rename to crates/marketing-server/src/error/mod.rs index 6c34d56..a02eb87 100644 --- a/crates/marketing-server/src/error.rs +++ b/crates/marketing-server/src/error/mod.rs @@ -1,69 +1,68 @@ //! Crate-wide error responses. -use std::fmt; - use axum::{ Json, response::{IntoResponse, Response}, }; use serde::Serialize; +use thiserror::Error; /// Errors returned by the marketing automation service. -#[derive(Debug)] -pub enum Error { +#[derive(Debug, Error)] +pub enum ServiceError { /// MongoDB rejected an operation. - Database(mongodb::error::Error), + #[error("database operation failed")] + Database(#[source] mongodb::error::Error), /// An API request did not meet the contract. + #[error("{0}")] Validation(String), /// A requested resource does not exist. + #[error("{0}")] NotFound(String), /// `TinyFlows` could not accept an automation event. - Workflow(reqwest::Error), + #[error("workflow delivery failed")] + Workflow(#[source] reqwest::Error), } /// Convenient result alias for service operations. -pub type Result = std::result::Result; +pub type Result = std::result::Result; #[derive(Debug, Serialize)] struct ErrorBody { error: String, } -impl IntoResponse for Error { +impl IntoResponse for ServiceError { fn into_response(self) -> Response { let (status, error) = match self { - Self::Database(error) => ( + Self::Database(_) => ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, - error.to_string(), + "internal server error".into(), ), Self::Validation(error) => (axum::http::StatusCode::BAD_REQUEST, error), Self::NotFound(error) => (axum::http::StatusCode::NOT_FOUND, error), - Self::Workflow(error) => (axum::http::StatusCode::BAD_GATEWAY, error.to_string()), + Self::Workflow(_) => ( + axum::http::StatusCode::BAD_GATEWAY, + "workflow delivery failed".into(), + ), }; (status, Json(ErrorBody { error })).into_response() } } -impl From for Error { +impl From for ServiceError { fn from(error: mongodb::error::Error) -> Self { Self::Database(error) } } - -impl From for Error { +impl From for ServiceError { fn from(error: reqwest::Error) -> Self { Self::Workflow(error) } } -impl fmt::Display for Error { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Database(error) => write!(formatter, "database error: {error}"), - Self::Validation(error) | Self::NotFound(error) => formatter.write_str(error), - Self::Workflow(error) => write!(formatter, "workflow error: {error}"), - } - } -} +/// Backwards-compatible public error name. +pub type Error = ServiceError; -impl std::error::Error for Error {} +#[cfg(test)] +mod test; diff --git a/crates/marketing-server/src/error/test.rs b/crates/marketing-server/src/error/test.rs new file mode 100644 index 0000000..a302aff --- /dev/null +++ b/crates/marketing-server/src/error/test.rs @@ -0,0 +1,45 @@ +//! HTTP response tests for service errors. + +use axum::{body::to_bytes, response::IntoResponse}; + +use super::ServiceError; + +async fn response_body(error: ServiceError) -> Result<(u16, String), Box> { + let response = error.into_response(); + let status = response.status().as_u16(); + let body = String::from_utf8(to_bytes(response.into_body(), usize::MAX).await?.to_vec())?; + Ok((status, body)) +} + +#[tokio::test] +async fn validation_and_missing_records_return_safe_client_errors() +-> Result<(), Box> { + let (status, body) = response_body(ServiceError::Validation("bad input".into())).await?; + assert_eq!(status, 400); + assert_eq!(body, r#"{"error":"bad input"}"#); + + let (status, body) = response_body(ServiceError::NotFound("absent".into())).await?; + assert_eq!(status, 404); + assert_eq!(body, r#"{"error":"absent"}"#); + Ok(()) +} + +#[tokio::test] +async fn infrastructure_errors_do_not_expose_internal_details() +-> Result<(), Box> { + let database = mongodb::error::Error::custom("database credential leaked"); + let (status, body) = response_body(ServiceError::from(database)).await?; + assert_eq!(status, 500); + assert_eq!(body, r#"{"error":"internal server error"}"#); + + let request_error = reqwest::Client::new() + .get("http://127.0.0.1:1") + .send() + .await + .err() + .ok_or_else(|| std::io::Error::other("closed local port rejects the request"))?; + let (status, body) = response_body(ServiceError::from(request_error)).await?; + assert_eq!(status, 502); + assert_eq!(body, r#"{"error":"workflow delivery failed"}"#); + Ok(()) +} diff --git a/crates/marketing-server/src/main.rs b/crates/marketing-server/src/main.rs index 0679d1f..c875c74 100644 --- a/crates/marketing-server/src/main.rs +++ b/crates/marketing-server/src/main.rs @@ -1,29 +1,141 @@ //! Executable entrypoint for the marketing automation API. use marketing_server::{AnalyticsWriter, AppState, Config, GoogleOAuth, TinyFlowsClient, router}; -use mongodb::Client; +use mongodb::{Client, Database}; + +const DEFAULT_LISTEN_ADDRESS: &str = "0.0.0.0:3000"; + +type StartupResult = Result>; + +struct RuntimeDependencies { + database: Database, + workflows: TinyFlowsClient, + analytics: AnalyticsWriter, + auth: GoogleOAuth, + public_base_url: String, +} /// Starts the Axum server using environment-backed infrastructure settings. #[tokio::main] -async fn main() -> Result<(), Box> { - let config = Config::from_env()?; +async fn main() -> StartupResult<()> { + run(Config::from_env()?, DEFAULT_LISTEN_ADDRESS).await +} + +async fn run(config: Config, listen_address: &str) -> StartupResult<()> { + let listener = tokio::net::TcpListener::bind(listen_address).await?; + serve(config, listener).await +} + +async fn serve(config: Config, listener: tokio::net::TcpListener) -> StartupResult<()> { + let dependencies = runtime_dependencies(config).await?; + let state = AppState::new( + dependencies.database, + dependencies.workflows, + dependencies.analytics, + dependencies.auth, + dependencies.public_base_url, + ) + .await?; + axum::serve(listener, router(state)).await?; + Ok(()) +} + +async fn runtime_dependencies(config: Config) -> StartupResult { let database = Client::with_uri_str(&config.mongodb_uri) .await? .database(&config.mongodb_database); - let state = AppState::new( + Ok(RuntimeDependencies { database, - TinyFlowsClient::new(config.tinyflows_webhook_url), - AnalyticsWriter::new(&config.clickhouse_url, &config.clickhouse_database), - GoogleOAuth::new( + workflows: TinyFlowsClient::new(config.tinyflows_webhook_url), + analytics: AnalyticsWriter::new(&config.clickhouse_url, &config.clickhouse_database), + auth: GoogleOAuth::new( config.google_client_id, config.google_client_secret, config.google_redirect_url, config.jwt_secret, ), - config.public_base_url, - ) - .await?; - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, router(state)).await?; - Ok(()) + public_base_url: config.public_base_url, + }) +} + +#[cfg(test)] +mod test { + //! Entrypoint tests using an isolated MongoDB container and ephemeral listener. + + use std::time::Duration; + + use mongodb::Client; + use testcontainers::{ + GenericImage, + core::{IntoContainerPort, WaitFor}, + runners::AsyncRunner, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + time::timeout, + }; + + use super::{StartupResult, run, runtime_dependencies, serve}; + use marketing_server::Config; + + fn config(mongodb_uri: String) -> Config { + Config { + mongodb_uri, + mongodb_database: "entrypoint_contract".into(), + tinyflows_webhook_url: "http://127.0.0.1:9/tinyflows".into(), + public_base_url: "https://dashboard.example.test".into(), + clickhouse_url: "http://127.0.0.1:9".into(), + clickhouse_database: "analytics".into(), + google_client_id: "client-id".into(), + google_client_secret: "client-secret".into(), + google_redirect_url: "https://dashboard.example.test/api/auth/google/callback".into(), + jwt_secret: "a-32-byte-secret-for-entrypoint-tests".into(), + } + } + + #[tokio::test] + async fn rejects_invalid_listener_and_mongodb_addresses_without_starting_a_server() { + assert!( + run(config("mongodb://127.0.0.1:1".into()), "not-an-address") + .await + .is_err() + ); + assert!( + runtime_dependencies(config("not a mongodb uri".into())) + .await + .is_err() + ); + } + + #[tokio::test] + async fn serves_health_from_an_ephemeral_listener() -> StartupResult<()> { + let container = GenericImage::new("mongo", "8.0.0") + .with_exposed_port(27017.tcp()) + .with_wait_for(WaitFor::message_on_either_std("Waiting for connections")) + .start() + .await?; + let port = container.get_host_port_ipv4(27017.tcp()).await?; + let mongodb_uri = format!("mongodb://127.0.0.1:{port}"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let server = tokio::spawn(serve(config(mongodb_uri.clone()), listener)); + + let mut stream = timeout(Duration::from_secs(5), TcpStream::connect(address)).await??; + stream + .write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await?; + let mut response = [0; 1024]; + let response_length = timeout(Duration::from_secs(5), stream.read(&mut response)).await??; + assert!(std::str::from_utf8(&response[..response_length])?.starts_with("HTTP/1.1 204")); + + server.abort(); + let _ = server.await; + Client::with_uri_str(&format!("{mongodb_uri}/?directConnection=true")) + .await? + .database("entrypoint_contract") + .drop() + .await?; + Ok(()) + } } diff --git a/crates/marketing-server/src/models.rs b/crates/marketing-server/src/models.rs index c2f7946..607e28f 100644 --- a/crates/marketing-server/src/models.rs +++ b/crates/marketing-server/src/models.rs @@ -1,8 +1,10 @@ //! API and persistence model definitions. -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use mongodb::bson::oid::ObjectId; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::SerializeStruct, +}; /// Request to create a contact. #[derive(Debug, Deserialize)] @@ -28,8 +30,10 @@ pub struct Contact { /// Opaque token embedded in the unsubscribe URL. pub unsubscribe_token: String, /// Record creation time. + #[serde(with = "bson_datetime")] pub created_at: DateTime, /// Most recent subscription preference change. + #[serde(with = "bson_datetime")] pub updated_at: DateTime, } @@ -45,7 +49,7 @@ pub struct CreateCampaign { } /// A campaign draft or launched campaign. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize)] pub struct Campaign { /// MongoDB document identifier. #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] @@ -59,13 +63,15 @@ pub struct Campaign { /// Current lifecycle state. pub status: CampaignStatus, /// Record creation time. + #[serde(with = "bson_datetime")] pub created_at: DateTime, /// Launch time, if the flow was started. + #[serde(deserialize_with = "bson_datetime_option::deserialize")] pub launched_at: Option>, } /// Campaign lifecycle state. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum CampaignStatus { /// Campaign can be edited but has not entered an automation flow. @@ -75,3 +81,162 @@ pub enum CampaignStatus { /// Campaign delivery automation has started. Launched, } + +/// The result of requesting a campaign launch. +#[derive(Clone, Debug)] +pub struct LaunchOutcome { + /// The campaign after its launch state was resolved. + pub campaign: Campaign, + /// Whether this request moved the campaign into its final launched state. + pub newly_launched: bool, +} + +impl Serialize for Campaign { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + let mut state = + serializer.serialize_struct("Campaign", 7 + usize::from(self.id.is_some()))?; + if let Some(id) = self.id { + state.serialize_field("_id", &id)?; + } + state.serialize_field("name", &self.name)?; + state.serialize_field("subject", &self.subject)?; + state.serialize_field("html_body", &self.html_body)?; + state.serialize_field("status", &self.status)?; + state.serialize_field("created_at", &BsonDateTime(&self.created_at))?; + state.serialize_field("launched_at", &self.launched_at.as_ref().map(BsonDateTime))?; + state.end() + } +} + +/// Borrowed UTC timestamp that serializes as a BSON datetime. +struct BsonDateTime<'a>(&'a DateTime); + +impl Serialize for BsonDateTime<'_> { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + mongodb::bson::DateTime::from_millis(self.0.timestamp_millis()).serialize(serializer) + } +} + +/// Serde adapter that persists a UTC timestamp as a BSON datetime. +mod bson_datetime { + use super::*; + + pub(super) fn serialize( + value: &DateTime, + serializer: S, + ) -> std::result::Result + where + S: Serializer, + { + mongodb::bson::DateTime::from_millis(value.timestamp_millis()).serialize(serializer) + } + + pub(super) fn deserialize<'de, D>( + deserializer: D, + ) -> std::result::Result, D::Error> + where + D: Deserializer<'de>, + { + let value = mongodb::bson::DateTime::deserialize(deserializer)?; + Utc.timestamp_millis_opt(value.timestamp_millis()) + .single() + .ok_or_else(|| D::Error::custom("BSON datetime is outside Chrono's range")) + } +} + +/// Serde adapter that persists an optional UTC timestamp as a BSON datetime. +mod bson_datetime_option { + use super::*; + + pub(super) fn deserialize<'de, D>( + deserializer: D, + ) -> std::result::Result>, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer)?.map_or(Ok(None), |value| { + Utc.timestamp_millis_opt(value.timestamp_millis()) + .single() + .map(Some) + .ok_or_else(|| D::Error::custom("BSON datetime is outside Chrono's range")) + }) + } +} + +#[cfg(test)] +mod test { + //! Serialization tests for MongoDB persistence models. + + use chrono::{TimeZone, Utc}; + use mongodb::bson::{Bson, to_document}; + + use super::{Campaign, CampaignStatus, Contact}; + + #[test] + fn contact_timestamps_use_bson_datetime_values() -> Result<(), Box> { + let timestamp = Utc + .with_ymd_and_hms(2026, 9, 20, 12, 34, 56) + .single() + .ok_or_else(|| std::io::Error::other("fixed test timestamp is valid"))?; + let contact = Contact { + id: None, + email: "ada@example.com".into(), + first_name: Some("Ada".into()), + subscribed: true, + unsubscribe_token: "token".into(), + created_at: timestamp, + updated_at: timestamp, + }; + + let document = to_document(&contact)?; + + assert!(matches!( + document.get("created_at"), + Some(Bson::DateTime(_)) + )); + assert!(matches!( + document.get("updated_at"), + Some(Bson::DateTime(_)) + )); + Ok(()) + } + + #[test] + fn campaign_round_trips_bson_datetime_and_status() -> Result<(), Box> { + let timestamp = Utc + .with_ymd_and_hms(2026, 9, 20, 12, 34, 56) + .single() + .ok_or_else(|| std::io::Error::other("fixed test timestamp is valid"))?; + let campaign = Campaign { + id: None, + name: "Launch".into(), + subject: "Hello".into(), + html_body: "unsubscribe".into(), + status: CampaignStatus::Launched, + created_at: timestamp, + launched_at: Some(timestamp), + }; + + let document = to_document(&campaign)?; + assert!(matches!( + document.get("created_at"), + Some(Bson::DateTime(_)) + )); + assert!(matches!( + document.get("launched_at"), + Some(Bson::DateTime(_)) + )); + assert_eq!(document.get_str("status")?, "launched"); + let decoded: Campaign = mongodb::bson::from_document(document)?; + + assert_eq!(decoded.status, CampaignStatus::Launched); + assert_eq!(decoded.launched_at, Some(timestamp)); + Ok(()) + } +} diff --git a/crates/marketing-server/src/repository.rs b/crates/marketing-server/src/repository.rs index f2c238e..375a3ea 100644 --- a/crates/marketing-server/src/repository.rs +++ b/crates/marketing-server/src/repository.rs @@ -3,14 +3,15 @@ use chrono::Utc; use mongodb::{ Collection, Database, IndexModel, - bson::{doc, oid::ObjectId}, + bson::{Bson, doc, oid::ObjectId}, options::IndexOptions, }; use crate::{ error::{Error, Result}, - models::{Campaign, CampaignStatus, Contact, CreateCampaign, CreateContact}, + models::{Campaign, CampaignStatus, Contact, CreateCampaign, CreateContact, LaunchOutcome}, }; +use uuid::Uuid; const UNSUBSCRIBE_PLACEHOLDER: &str = "{{unsubscribe_url}}"; @@ -58,36 +59,9 @@ impl MarketingRepository { /// /// Returns validation or database errors. pub async fn create_contact(&self, input: CreateContact) -> Result { - let email = input.email.trim().to_lowercase(); - let mut parts = email.split('@'); - if email.chars().any(char::is_whitespace) - || parts.next().is_none_or(str::is_empty) - || parts.next().is_none_or(str::is_empty) - || parts.next().is_some() - { - return Err(Error::Validation("email address is invalid".into())); - } - let now = Utc::now(); - let mut contact = Contact { - id: None, - email, - first_name: input.first_name.filter(|name| !name.trim().is_empty()), - subscribed: true, - unsubscribe_token: ObjectId::new().to_hex(), - created_at: now, - updated_at: now, - }; - let inserted = self - .contacts - .insert_one(&contact) - .await? - .inserted_id - .as_object_id() - .ok_or_else(|| { - Error::Database(mongodb::error::Error::custom( - "MongoDB did not return an object id", - )) - })?; + let mut contact = new_contact(input, Utc::now())?; + let insert_result = self.contacts.insert_one(&contact).await?; + let inserted = inserted_object_id(&insert_result.inserted_id)?; contact.id = Some(inserted); Ok(contact) } @@ -98,51 +72,46 @@ impl MarketingRepository { /// /// Returns validation or database errors. pub async fn create_campaign(&self, input: CreateCampaign) -> Result { - if input.name.trim().is_empty() || input.subject.trim().is_empty() { - return Err(Error::Validation( - "campaign name and subject are required".into(), - )); - } - if !input.html_body.contains(UNSUBSCRIBE_PLACEHOLDER) { - return Err(Error::Validation(format!( - "campaign HTML must include {UNSUBSCRIBE_PLACEHOLDER}" - ))); - } - let mut campaign = Campaign { - id: None, - name: input.name, - subject: input.subject, - html_body: input.html_body, - status: CampaignStatus::Draft, - created_at: Utc::now(), - launched_at: None, - }; - let inserted = self - .campaigns - .insert_one(&campaign) - .await? - .inserted_id - .as_object_id() - .ok_or_else(|| { - Error::Database(mongodb::error::Error::custom( - "MongoDB did not return an object id", - )) - })?; + let mut campaign = new_campaign(input, Utc::now())?; + let insert_result = self.campaigns.insert_one(&campaign).await?; + let inserted = inserted_object_id(&insert_result.inserted_id)?; campaign.id = Some(inserted); Ok(campaign) } - /// Marks a campaign as launched and returns it. + /// Marks a campaign as launched and reports whether this request changed its state. /// /// # Errors /// /// Returns not-found or database errors. - pub async fn launch_campaign(&self, id: &str) -> Result { + pub async fn launch_campaign(&self, id: &str) -> Result { let id = ObjectId::parse_str(id) .map_err(|_| Error::Validation("campaign id is invalid".into()))?; let now = Utc::now(); - let campaign = self.campaigns.find_one_and_update(doc! { "_id": id }, doc! { "$set": { "status": "launching", "launched_at": mongodb::bson::DateTime::from_millis(now.timestamp_millis()) } }).return_document(mongodb::options::ReturnDocument::After).await?; - campaign.ok_or_else(|| Error::NotFound("campaign not found".into())) + if let Some(campaign) = self + .campaigns + .find_one_and_update( + doc! { "_id": id, "status": { "$ne": "launched" } }, + doc! { "$set": { "status": "launched", "launched_at": mongodb::bson::DateTime::from_millis(now.timestamp_millis()) } }, + ) + .return_document(mongodb::options::ReturnDocument::After) + .await? + { + return Ok(LaunchOutcome { + campaign, + newly_launched: true, + }); + } + + let campaign = self + .campaigns + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| Error::NotFound("campaign not found".into()))?; + Ok(LaunchOutcome { + campaign, + newly_launched: false, + }) } /// Removes a contact from marketing delivery by token. @@ -155,3 +124,292 @@ impl MarketingRepository { contact.ok_or_else(|| Error::NotFound("unsubscribe link is invalid".into())) } } + +fn inserted_object_id(inserted_id: &Bson) -> Result { + inserted_id.as_object_id().ok_or_else(|| { + Error::Database(mongodb::error::Error::custom( + "MongoDB did not return an object id", + )) + }) +} + +fn new_contact(input: CreateContact, now: chrono::DateTime) -> Result { + Ok(Contact { + id: None, + email: normalize_email(&input.email)?, + first_name: input.first_name.filter(|name| !name.trim().is_empty()), + subscribed: true, + unsubscribe_token: Uuid::new_v4().simple().to_string(), + created_at: now, + updated_at: now, + }) +} + +fn normalize_email(email: &str) -> Result { + if email.is_empty() || email.len() > 254 || email.chars().any(char::is_whitespace) { + return Err(Error::Validation("email address is invalid".into())); + } + let (local, domain) = email + .split_once('@') + .filter(|(_, remainder)| !remainder.contains('@')) + .ok_or_else(|| Error::Validation("email address is invalid".into()))?; + let valid_local = !local.is_empty() + && local.len() <= 64 + && !local.starts_with('.') + && !local.ends_with('.') + && !local.contains("..") + && local + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".!#$%&'*+-/=?^_`{|}~".contains(&byte)); + let valid_domain = domain.contains('.') + && domain.len() <= 253 + && domain.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + if !(valid_local && valid_domain) { + return Err(Error::Validation("email address is invalid".into())); + } + Ok(email.to_lowercase()) +} + +fn new_campaign(input: CreateCampaign, now: chrono::DateTime) -> Result { + if input.name.trim().is_empty() || input.subject.trim().is_empty() { + return Err(Error::Validation( + "campaign name and subject are required".into(), + )); + } + if input + .html_body + .match_indices(UNSUBSCRIBE_PLACEHOLDER) + .count() + != 1 + { + return Err(Error::Validation(format!( + "campaign HTML must include exactly one {UNSUBSCRIBE_PLACEHOLDER}" + ))); + } + Ok(Campaign { + id: None, + name: input.name, + subject: input.subject, + html_body: input.html_body, + status: CampaignStatus::Draft, + created_at: now, + launched_at: None, + }) +} + +#[cfg(test)] +mod test { + //! Database-free tests for repository input and state construction. + + use chrono::{TimeZone, Utc}; + + use mongodb::{ + Client, + bson::{Bson, oid::ObjectId}, + }; + use testcontainers::{ + GenericImage, + core::{IntoContainerPort, WaitFor}, + runners::AsyncRunner, + }; + + use super::{ + MarketingRepository, UNSUBSCRIBE_PLACEHOLDER, inserted_object_id, new_campaign, + new_contact, normalize_email, + }; + use crate::{ + Error, + models::{CampaignStatus, CreateCampaign, CreateContact}, + }; + + fn timestamp() -> Result, std::io::Error> { + Utc.with_ymd_and_hms(2026, 9, 20, 12, 34, 56) + .single() + .ok_or_else(|| std::io::Error::other("fixed test timestamp is valid")) + } + + #[test] + fn normalizes_a_valid_email_address() -> Result<(), Box> { + assert_eq!( + normalize_email("Ada.Smith+news@Example.COM")?, + "ada.smith+news@example.com" + ); + Ok(()) + } + + #[test] + fn rejects_invalid_email_addresses() { + for email in [ + "", + " ada@example.com", + "ada @example.com", + "ada@@example.com", + ".ada@example.com", + "ada@example", + "ada@-example.com", + "ada@example..com", + ] { + assert!( + matches!(normalize_email(email), Err(Error::Validation(_))), + "{email} should be rejected" + ); + } + } + + #[test] + fn creates_contact_with_an_opaque_random_token() -> Result<(), Box> { + let contact = new_contact( + CreateContact { + email: "ada@example.com".into(), + first_name: Some(" ".into()), + }, + timestamp()?, + )?; + + assert_eq!(contact.first_name, None); + assert_eq!(contact.unsubscribe_token.len(), 32); + assert!( + contact + .unsubscribe_token + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + ); + assert_ne!( + contact.unsubscribe_token, + new_contact( + CreateContact { + email: "grace@example.com".into(), + first_name: None + }, + timestamp()? + )? + .unsubscribe_token + ); + Ok(()) + } + + #[test] + fn creates_a_draft_with_exactly_one_unsubscribe_placeholder() + -> Result<(), Box> { + let campaign = new_campaign( + CreateCampaign { + name: "September".into(), + subject: "Update".into(), + html_body: format!("unsubscribe"), + }, + timestamp()?, + )?; + + assert_eq!(campaign.status, CampaignStatus::Draft); + assert_eq!(campaign.created_at, timestamp()?); + assert_eq!(campaign.launched_at, None); + Ok(()) + } + + #[test] + fn rejects_missing_or_repeated_unsubscribe_placeholders() + -> Result<(), Box> { + for html_body in [ + "

No link

".into(), + format!("{UNSUBSCRIBE_PLACEHOLDER}{UNSUBSCRIBE_PLACEHOLDER}"), + ] { + let result = new_campaign( + CreateCampaign { + name: "September".into(), + subject: "Update".into(), + html_body, + }, + timestamp()?, + ); + assert!(matches!(result, Err(Error::Validation(_)))); + } + Ok(()) + } + + #[test] + fn rejects_a_non_object_id_insert_result() { + assert!(matches!( + inserted_object_id(&Bson::Null), + Err(Error::Database(_)) + )); + } + + #[tokio::test] + async fn persists_contacts_campaigns_and_suppression_state() + -> Result<(), Box> { + let container = GenericImage::new("mongo", "8.0.0") + .with_exposed_port(27017.tcp()) + .with_wait_for(WaitFor::message_on_either_std("Waiting for connections")) + .start() + .await?; + let port = container.get_host_port_ipv4(27017.tcp()).await?; + let database = Client::with_uri_str(format!("mongodb://127.0.0.1:{port}")) + .await? + .database("repository_contract"); + let repository = MarketingRepository::new(database).await?; + + let contact = repository + .create_contact(CreateContact { + email: "Ada@example.com".into(), + first_name: Some("Ada".into()), + }) + .await?; + assert!(contact.id.is_some()); + assert_eq!(contact.email, "ada@example.com"); + assert_eq!(contact.first_name.as_deref(), Some("Ada")); + assert!(contact.subscribed); + + let duplicate = repository + .create_contact(CreateContact { + email: "ada@example.com".into(), + first_name: None, + }) + .await; + assert!(matches!(duplicate, Err(Error::Database(_)))); + + let campaign = repository + .create_campaign(CreateCampaign { + name: "September update".into(), + subject: "News".into(), + html_body: format!("Unsubscribe"), + }) + .await?; + let campaign_id = campaign.id.ok_or("campaign id is assigned")?.to_hex(); + assert_eq!(campaign.status, CampaignStatus::Draft); + + assert!(matches!( + repository.launch_campaign("not-an-id").await, + Err(Error::Validation(_)) + )); + assert!(matches!( + repository.launch_campaign(&ObjectId::new().to_hex()).await, + Err(Error::NotFound(_)) + )); + + let launched = repository.launch_campaign(&campaign_id).await?; + assert!(launched.newly_launched); + assert_eq!(launched.campaign.status, CampaignStatus::Launched); + assert!(launched.campaign.launched_at.is_some()); + + let repeated_launch = repository.launch_campaign(&campaign_id).await?; + assert!(!repeated_launch.newly_launched); + assert_eq!(repeated_launch.campaign.status, CampaignStatus::Launched); + + let unsubscribed = repository.unsubscribe(&contact.unsubscribe_token).await?; + assert!(!unsubscribed.subscribed); + assert!(matches!( + repository.unsubscribe("unknown-token").await, + Err(Error::NotFound(_)) + )); + + Ok(()) + } +} diff --git a/crates/marketing-server/src/tinyflows.rs b/crates/marketing-server/src/tinyflows.rs index 43fa14b..d875a74 100644 --- a/crates/marketing-server/src/tinyflows.rs +++ b/crates/marketing-server/src/tinyflows.rs @@ -56,3 +56,6 @@ impl TinyFlowsClient { Ok(()) } } + +#[cfg(test)] +mod test; diff --git a/crates/marketing-server/src/tinyflows/test.rs b/crates/marketing-server/src/tinyflows/test.rs new file mode 100644 index 0000000..436ccfe --- /dev/null +++ b/crates/marketing-server/src/tinyflows/test.rs @@ -0,0 +1,131 @@ +//! Tests for `TinyFlows` webhook delivery. + +use std::{ + io::{Error as IoError, ErrorKind, Read, Write}, + net::{TcpListener, TcpStream}, + thread, +}; + +use serde_json::{Value, json}; + +use crate::error::Error; + +use super::TinyFlowsClient; + +struct CapturedWebhook { + request_line: String, + body: Vec, +} + +type TestResult = Result>; +type WebhookServer = (String, thread::JoinHandle>); + +fn webhook_server(status: u16) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0")?; + let url = format!("http://{}", listener.local_addr()?); + let server = thread::spawn(move || -> std::io::Result { + let (mut stream, _) = listener.accept()?; + let mut request = [0_u8; 4096]; + let request_len = stream.read(&mut request)?; + let request = &request[..request_len]; + let header_end = request + .windows(4) + .position(|bytes| bytes == b"\r\n\r\n") + .ok_or_else(|| IoError::new(ErrorKind::UnexpectedEof, "request has no headers"))? + + 4; + let (request_line, content_length) = { + let headers = std::str::from_utf8(&request[..header_end]).map_err(IoError::other)?; + let request_line = headers + .lines() + .next() + .ok_or_else(|| IoError::other("webhook request includes a request line"))? + .into(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }) + .ok_or_else(|| IoError::other("webhook request includes a content length"))? + .parse::() + .map_err(IoError::other)?; + (request_line, content_length) + }; + stream.write_all( + format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .as_bytes(), + )?; + Ok(CapturedWebhook { + request_line, + body: request[header_end..header_end + content_length].to_vec(), + }) + }); + Ok((url, server)) +} + +#[tokio::test] +async fn trigger_posts_the_expected_workflow_envelope() -> Result<(), Box> { + let (url, server) = webhook_server(204)?; + let client = TinyFlowsClient::new(url); + + client + .trigger("campaign_sent", &json!({ "campaign_id": "campaign-42" })) + .await?; + + let request = server + .join() + .map_err(|_| IoError::other("test webhook thread panicked"))??; + assert_eq!(request.request_line, "POST / HTTP/1.1"); + assert_eq!( + serde_json::from_slice::(&request.body)?, + json!({ + "workflow": "email-marketing", + "event": "campaign_sent", + "data": { "campaign_id": "campaign-42" }, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn trigger_maps_rejected_webhooks_to_workflow_errors() +-> Result<(), Box> { + let (url, server) = webhook_server(500)?; + let client = TinyFlowsClient::new(url); + + let result = client.trigger("campaign_sent", &json!({})).await; + + let _body = server + .join() + .map_err(|_| IoError::other("test webhook thread panicked"))??; + assert!(matches!(result, Err(Error::Workflow(_)))); + Ok(()) +} + +#[test] +fn tinyflows_client_debug_output_redacts_the_webhook_url() { + let client = TinyFlowsClient::new("https://secret@example.test/webhook".into()); + + let debug = format!("{client:?}"); + + assert!(debug.contains("TinyFlowsClient")); + assert!(!debug.contains("secret")); + assert!(!debug.contains("example.test")); +} + +#[test] +fn webhook_harness_rejects_requests_without_a_content_length() +-> Result<(), Box> { + let (url, server) = webhook_server(204)?; + let address = url.strip_prefix("http://").ok_or("loopback server URL")?; + let mut stream = TcpStream::connect(address)?; + stream.write_all(b"POST / HTTP/1.1\r\nHost: localhost\r\n\r\n")?; + drop(stream); + + let result = server + .join() + .map_err(|_| IoError::other("test webhook thread panicked"))?; + assert!(result.is_err()); + Ok(()) +} From 5552e4d4d5095d217a34d68a38313d20a2b6ec0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 21:59:33 +0300 Subject: [PATCH 4/5] Serialize ClickHouse timestamps correctly Co-authored-by: Medulla --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/marketing-server/src/analytics.rs | 1 + crates/marketing-server/src/analytics/test.rs | 2 +- crates/marketing-server/src/auth.rs | 11 ++++++++++- crates/marketing-server/src/auth/test.rs | 17 ++++++++++++++++- 6 files changed, 30 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1bfaa2..67bbe71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,6 +399,7 @@ checksum = "9a9a81a1dffadd762ee662635ce409232258ce9beebd7cc0fa227df0b5e7efc0" dependencies = [ "bstr", "bytes", + "chrono", "cityhash-rs", "clickhouse-derive", "futures", diff --git a/Cargo.toml b/Cargo.toml index 65deb55..b6055be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # CORS middleware allows the local Vite dashboard to call the Axum API. tower-http = { version = "0.6", features = ["cors"] } # Native ClickHouse client used for immutable engagement analytics events. -clickhouse = "0.13" +clickhouse = { version = "0.13", features = ["chrono"] } # Signed API session tokens issued after a verified Google OAuth identity. jsonwebtoken = "9" # URL encoding for Google OAuth authorization URLs. diff --git a/crates/marketing-server/src/analytics.rs b/crates/marketing-server/src/analytics.rs index d9eb07c..b401048 100644 --- a/crates/marketing-server/src/analytics.rs +++ b/crates/marketing-server/src/analytics.rs @@ -18,6 +18,7 @@ pub struct CampaignEvent { /// Associated contact identifier when available. pub contact_id: String, /// Event timestamp in UTC. + #[serde(with = "clickhouse::serde::chrono::datetime64::millis")] pub occurred_at: DateTime, } diff --git a/crates/marketing-server/src/analytics/test.rs b/crates/marketing-server/src/analytics/test.rs index b726fe0..ff0171a 100644 --- a/crates/marketing-server/src/analytics/test.rs +++ b/crates/marketing-server/src/analytics/test.rs @@ -22,7 +22,7 @@ fn campaign_event_serializes_the_clickhouse_column_names() -> Result<(), Box) -> fmt::Result { + formatter + .debug_struct("Session") + .field("email", &self.email) + .finish_non_exhaustive() + } +} + #[derive(Debug, Deserialize)] struct TokenResponse { access_token: String, diff --git a/crates/marketing-server/src/auth/test.rs b/crates/marketing-server/src/auth/test.rs index 12ebecd..b8c7377 100644 --- a/crates/marketing-server/src/auth/test.rs +++ b/crates/marketing-server/src/auth/test.rs @@ -9,7 +9,9 @@ use std::{ thread, }; -use super::{Claims, GoogleOAuth, GoogleUser, StateClaims, TokenResponse, validated_access_token}; +use super::{ + Claims, GoogleOAuth, GoogleUser, Session, StateClaims, TokenResponse, validated_access_token, +}; type TestResult = Result>; @@ -144,6 +146,19 @@ fn debug_output_does_not_expose_secrets() { assert!(!output.contains("secret\"")); } +#[test] +fn session_debug_output_redacts_the_bearer_token() { + let session = Session { + token: "dashboard-bearer-token".into(), + email: "person@example.test".into(), + }; + + let debug = format!("{session:?}"); + + assert!(debug.contains("person@example.test")); + assert!(!debug.contains("dashboard-bearer-token")); +} + #[test] fn generated_state_is_valid_and_unique() -> TestResult { let oauth = oauth(); From bdafded8d2d7c359679a141c8dfd1b84893f05cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 22:15:12 +0300 Subject: [PATCH 5/5] Harden campaign launch and unsubscribe flows Co-authored-by: Medulla --- crates/marketing-server/src/api.rs | 65 ++++++++++-- crates/marketing-server/src/api/test.rs | 86 ++++++++++++++-- crates/marketing-server/src/auth.rs | 10 +- crates/marketing-server/src/auth/test.rs | 12 +++ crates/marketing-server/src/config.rs | 25 +++++ crates/marketing-server/src/config/test.rs | 22 ++++- crates/marketing-server/src/main.rs | 4 + crates/marketing-server/src/models.rs | 36 ++++++- crates/marketing-server/src/repository.rs | 109 +++++++++++++++++---- 9 files changed, 330 insertions(+), 39 deletions(-) diff --git a/crates/marketing-server/src/api.rs b/crates/marketing-server/src/api.rs index ce185ff..88ed259 100644 --- a/crates/marketing-server/src/api.rs +++ b/crates/marketing-server/src/api.rs @@ -14,6 +14,7 @@ use chrono::Utc; use mongodb::Database; use serde::{Deserialize, Serialize}; use tower_http::cors::{Any, CorsLayer}; +use url::Url; use crate::{ AnalyticsWriter, GoogleOAuth, MarketingRepository, TinyFlowsClient, @@ -30,6 +31,8 @@ pub struct AppState { analytics: AnalyticsWriter, auth: GoogleOAuth, public_base_url: String, + dashboard_origin: HeaderValue, + public_origin: String, } impl AppState { @@ -44,20 +47,30 @@ impl AppState { analytics: AnalyticsWriter, auth: GoogleOAuth, public_base_url: String, + dashboard_origin: String, ) -> Result { + let dashboard_origin = HeaderValue::from_str(&dashboard_origin) + .map_err(|error| crate::Error::Validation(error.to_string()))?; + let public_origin = Url::parse(&public_base_url) + .map_err(|_| { + crate::Error::Validation("PUBLIC_BASE_URL must be an absolute HTTP URL".into()) + })? + .origin() + .ascii_serialization(); Ok(Self { repository: MarketingRepository::new(database).await?, workflows, analytics, auth, public_base_url, + dashboard_origin, + public_origin, }) } } /// Creates the application router. pub fn router(state: AppState) -> Router { - let dashboard_origin = HeaderValue::from_static("http://localhost:5173"); let state = Arc::new(state); let protected_api = Router::new() .route("/api/contacts", post(create_contact)) @@ -78,7 +91,7 @@ pub fn router(state: AppState) -> Router { .merge(protected_api) .layer( CorsLayer::new() - .allow_origin(dashboard_origin) + .allow_origin(state.dashboard_origin.clone()) .allow_methods([Method::GET, Method::POST]) .allow_headers(Any), ) @@ -142,11 +155,13 @@ async fn launch_campaign( .analytics .record(&CampaignEvent { event_name: "campaign_launched".into(), - campaign_id: id, + campaign_id: id.clone(), contact_id: String::new(), occurred_at: Utc::now(), }) .await?; + let campaign = state.repository.complete_campaign_launch(&id).await?; + return Ok(Json(campaign)); } Ok(Json(outcome.campaign)) } @@ -156,7 +171,7 @@ async fn google_login(State(state): State>) -> Result(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { }) } -async fn unsubscribe_page() -> Html<&'static str> { - Html( - "Unsubscribe

Unsubscribe from email

Use the form in the message to stop marketing email.

", - ) +async fn unsubscribe_page(Path(token): Path) -> Result> { + if !is_unsubscribe_token(&token) { + return Err(crate::Error::Validation( + "unsubscribe link is invalid".into(), + )); + } + let action_token = html_attribute_escape(&token); + Ok(Html(format!( + "Unsubscribe

Unsubscribe from email

" + ))) } async fn unsubscribe( State(state): State>, + headers: HeaderMap, Path(token): Path, ) -> Result> { + if !has_public_origin(&headers, &state.public_origin) { + return Err(crate::Error::Validation( + "unsubscribe request has an invalid origin".into(), + )); + } let contact = state.repository.unsubscribe(&token).await?; state .workflows @@ -248,13 +275,33 @@ async fn unsubscribe( )) } +fn has_public_origin(headers: &HeaderMap, public_origin: &str) -> bool { + headers + .get(header::ORIGIN) + .and_then(|origin| origin.to_str().ok()) + .is_some_and(|origin| origin == public_origin) +} + +fn is_unsubscribe_token(token: &str) -> bool { + token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn html_attribute_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('\'', "'") + .replace('<', "<") + .replace('>', ">") +} + impl fmt::Debug for AppState { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.debug_struct("AppState").finish_non_exhaustive() } } -#[derive(Debug, Serialize)] +#[derive(Serialize)] struct ContactResponse { email: String, subscribed: bool, diff --git a/crates/marketing-server/src/api/test.rs b/crates/marketing-server/src/api/test.rs index 825962b..2632421 100644 --- a/crates/marketing-server/src/api/test.rs +++ b/crates/marketing-server/src/api/test.rs @@ -21,8 +21,9 @@ use tokio::{ use tower::ServiceExt; use super::{ - ContactResponse, callback_state_is_valid, clear_oauth_state_cookie, cookie_value, health, - login_response, oauth_state_cookie, unsubscribe_page, + ContactResponse, callback_state_is_valid, clear_oauth_state_cookie, cookie_value, + has_public_origin, health, html_attribute_escape, is_unsubscribe_token, login_response, + oauth_state_cookie, unsubscribe_page, }; use crate::{AnalyticsWriter, AppState, GoogleOAuth, TinyFlowsClient, models::Contact, router}; @@ -52,6 +53,31 @@ fn ignores_missing_cookie_headers() { assert_eq!(cookie_value(&HeaderMap::new(), "oauth_state"), None); } +#[test] +fn requires_the_configured_public_origin_for_unsubscribe_posts() { + let mut headers = HeaderMap::new(); + assert!(!has_public_origin( + &headers, + "https://dashboard.example.test" + )); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://attacker.example.test"), + ); + assert!(!has_public_origin( + &headers, + "https://dashboard.example.test" + )); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://dashboard.example.test"), + ); + assert!(has_public_origin( + &headers, + "https://dashboard.example.test" + )); +} + #[test] fn produces_a_secure_short_lived_oauth_cookie() -> Result<(), Box> { let cookie = oauth_state_cookie("signed-state")?; @@ -123,18 +149,38 @@ fn login_response_redirects_to_google_and_sets_the_bound_state_cookie() } #[tokio::test] -async fn health_route_is_empty_and_unsubscribe_page_is_static() +async fn health_route_is_empty_and_unsubscribe_page_posts_a_validated_token() -> Result<(), Box> { assert_eq!(health().await, StatusCode::NO_CONTENT); - let response = unsubscribe_page().await.into_response(); + let response = unsubscribe_page(axum::extract::Path( + "0123456789abcdef0123456789abcdef".into(), + )) + .await? + .into_response(); let body = to_bytes(response.into_body(), usize::MAX).await?; assert_eq!( std::str::from_utf8(&body)?, - "Unsubscribe

Unsubscribe from email

Use the form in the message to stop marketing email.

" + "Unsubscribe

Unsubscribe from email

" + ); + assert!( + unsubscribe_page(axum::extract::Path("invalid".into())) + .await + .is_err() ); Ok(()) } +#[test] +fn validates_opaque_tokens_and_escapes_html_attribute_values() { + assert!(is_unsubscribe_token("0123456789abcdef0123456789abcdef")); + assert!(!is_unsubscribe_token("short")); + assert!(!is_unsubscribe_token("0123456789abcdef0123456789abcdeg")); + assert_eq!( + html_attribute_escape("&\""), + "<a href='x'>&"</a>" + ); +} + #[test] fn contact_response_uses_a_single_normalized_public_base_url() -> Result<(), Box> { @@ -206,9 +252,21 @@ async fn start_http_server(requests: usize) -> Result Result<(), Box> { let health = app .clone() - .oneshot(Request::builder().uri("/health").body(Body::empty())?) + .oneshot( + Request::builder() + .uri("/health") + .header(header::ORIGIN, "https://dashboard.example.test") + .body(Body::empty())?, + ) .await?; assert_eq!(health.status(), StatusCode::NO_CONTENT); + assert_eq!( + health + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("https://dashboard.example.test") + ); let login = app .clone() @@ -295,7 +353,7 @@ async fn creates_and_launches_campaign( let id = campaign["_id"]["$oid"] .as_str() .ok_or("campaign has Mongo extended JSON id")?; - for expected_status in [StatusCode::OK, StatusCode::OK] { + for expected_status in [StatusCode::OK, StatusCode::BAD_REQUEST] { let response = app .clone() .oneshot( @@ -330,6 +388,7 @@ async fn router_authenticates_mutations_and_drives_the_campaign_lifecycle() AnalyticsWriter::new(&http_url, "analytics"), auth(), "https://dashboard.example.test/".into(), + "https://dashboard.example.test".into(), ) .await?; let app = router(state); @@ -353,11 +412,24 @@ async fn router_authenticates_mutations_and_drives_the_campaign_lifecycle() let token = creates_contact(&app, &authorization).await?; creates_and_launches_campaign(&app, &authorization).await?; + let unsubscribe_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/unsubscribe/{token}")) + .header(header::ORIGIN, "https://attacker.example.test") + .body(Body::empty())?, + ) + .await?; + assert_eq!(unsubscribe_response.status(), StatusCode::BAD_REQUEST); + let unsubscribe_response = app .oneshot( Request::builder() .method("POST") .uri(format!("/unsubscribe/{token}")) + .header(header::ORIGIN, "https://dashboard.example.test") .body(Body::empty())?, ) .await?; diff --git a/crates/marketing-server/src/auth.rs b/crates/marketing-server/src/auth.rs index 9066aaa..45b2563 100644 --- a/crates/marketing-server/src/auth.rs +++ b/crates/marketing-server/src/auth.rs @@ -57,10 +57,18 @@ impl fmt::Debug for Session { } } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct TokenResponse { access_token: String, } + +impl fmt::Debug for TokenResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TokenResponse") + .finish_non_exhaustive() + } +} #[derive(Debug, Deserialize)] struct GoogleUser { sub: String, diff --git a/crates/marketing-server/src/auth/test.rs b/crates/marketing-server/src/auth/test.rs index b8c7377..c911b5e 100644 --- a/crates/marketing-server/src/auth/test.rs +++ b/crates/marketing-server/src/auth/test.rs @@ -159,6 +159,18 @@ fn session_debug_output_redacts_the_bearer_token() { assert!(!debug.contains("dashboard-bearer-token")); } +#[test] +fn token_response_debug_output_redacts_the_google_access_token() { + let response = TokenResponse { + access_token: "google-access-token".into(), + }; + + let debug = format!("{response:?}"); + + assert!(debug.contains("TokenResponse")); + assert!(!debug.contains("google-access-token")); +} + #[test] fn generated_state_is_valid_and_unique() -> TestResult { let oauth = oauth(); diff --git a/crates/marketing-server/src/config.rs b/crates/marketing-server/src/config.rs index 858cb00..077c9c7 100644 --- a/crates/marketing-server/src/config.rs +++ b/crates/marketing-server/src/config.rs @@ -16,6 +16,8 @@ pub struct Config { pub tinyflows_webhook_url: String, /// Public URL used when producing unsubscribe links. pub public_base_url: String, + /// Origin allowed to call dashboard API routes from a browser. + pub dashboard_origin: String, /// `ClickHouse` HTTP URL used for engagement analytics. pub clickhouse_url: String, /// `ClickHouse` database holding the event table. @@ -36,6 +38,7 @@ impl fmt::Debug for Config { .debug_struct("Config") .field("mongodb_database", &self.mongodb_database) .field("public_base_url", &self.public_base_url) + .field("dashboard_origin", &self.dashboard_origin) .field("clickhouse_database", &self.clickhouse_database) .field("google_client_id", &self.google_client_id) .finish_non_exhaustive() @@ -65,6 +68,8 @@ impl Config { }; let public_base_url = lookup("PUBLIC_BASE_URL").unwrap_or_else(|| "http://localhost:3000".into()); + let dashboard_origin = + lookup("DASHBOARD_ORIGIN").unwrap_or_else(|| "http://localhost:5173".into()); if public_base_url.trim().is_empty() { return Err(Error::Validation( "PUBLIC_BASE_URL must not be empty".into(), @@ -72,6 +77,7 @@ impl Config { } let google_redirect_url = required("GOOGLE_REDIRECT_URL")?; validate_http_url("PUBLIC_BASE_URL", &public_base_url)?; + validate_origin("DASHBOARD_ORIGIN", &dashboard_origin)?; validate_http_url("GOOGLE_REDIRECT_URL", &google_redirect_url)?; let jwt_secret = required("JWT_SECRET")?; if jwt_secret.len() < 32 { @@ -84,6 +90,7 @@ impl Config { mongodb_database: required("MONGODB_DATABASE")?, tinyflows_webhook_url: required("TINYFLOWS_WEBHOOK_URL")?, public_base_url, + dashboard_origin, clickhouse_url: required("CLICKHOUSE_URL")?, clickhouse_database: required("CLICKHOUSE_DATABASE")?, google_client_id: required("GOOGLE_CLIENT_ID")?, @@ -94,6 +101,24 @@ impl Config { } } +fn validate_origin(name: &str, value: &str) -> Result<()> { + let url = Url::parse(value) + .map_err(|_| Error::Validation(format!("{name} must be an absolute HTTP origin")))?; + if !matches!(url.scheme(), "http" | "https") + || url.host().is_none() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return Err(Error::Validation(format!( + "{name} must be an absolute HTTP origin" + ))); + } + Ok(()) +} + fn validate_http_url(name: &str, value: &str) -> Result<()> { let url = Url::parse(value) .map_err(|_| Error::Validation(format!("{name} must be an absolute HTTP URL")))?; diff --git a/crates/marketing-server/src/config/test.rs b/crates/marketing-server/src/config/test.rs index 41930a0..dc91e04 100644 --- a/crates/marketing-server/src/config/test.rs +++ b/crates/marketing-server/src/config/test.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::fmt::Display; -use super::{Config, validate_http_url}; +use super::{Config, validate_http_url, validate_origin}; type TestResult = Result<(), Box>; @@ -39,6 +39,7 @@ fn loads_required_values_and_default_public_url() -> TestResult { assert_eq!(config.mongodb_database, "marketing"); assert_eq!(config.public_base_url, "http://localhost:3000"); + assert_eq!(config.dashboard_origin, "http://localhost:5173"); assert_eq!( config.google_redirect_url, "https://dashboard.example.test/callback" @@ -46,6 +47,25 @@ fn loads_required_values_and_default_public_url() -> TestResult { Ok(()) } +#[test] +fn accepts_a_configured_dashboard_origin_and_rejects_non_origins() -> TestResult { + let mut values = valid_values(); + values.insert("DASHBOARD_ORIGIN", "https://dashboard.example.test".into()); + let config = Config::from_lookup(|name| values.get(name).cloned())?; + assert_eq!(config.dashboard_origin, "https://dashboard.example.test"); + + for origin in [ + "not a URL", + "https://dashboard.example.test/path", + "https://dashboard.example.test?query=true", + "ftp://dashboard.example.test", + "https://user:password@dashboard.example.test", + ] { + assert!(validate_origin("DASHBOARD_ORIGIN", origin).is_err()); + } + Ok(()) +} + #[test] fn accepts_an_explicit_https_public_url() -> TestResult { let mut values = valid_values(); diff --git a/crates/marketing-server/src/main.rs b/crates/marketing-server/src/main.rs index c875c74..21735c5 100644 --- a/crates/marketing-server/src/main.rs +++ b/crates/marketing-server/src/main.rs @@ -13,6 +13,7 @@ struct RuntimeDependencies { analytics: AnalyticsWriter, auth: GoogleOAuth, public_base_url: String, + dashboard_origin: String, } /// Starts the Axum server using environment-backed infrastructure settings. @@ -34,6 +35,7 @@ async fn serve(config: Config, listener: tokio::net::TcpListener) -> StartupResu dependencies.analytics, dependencies.auth, dependencies.public_base_url, + dependencies.dashboard_origin, ) .await?; axum::serve(listener, router(state)).await?; @@ -55,6 +57,7 @@ async fn runtime_dependencies(config: Config) -> StartupResult, } +impl fmt::Debug for Contact { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Contact") + .field("id", &self.id) + .field("email", &self.email) + .field("first_name", &self.first_name) + .field("subscribed", &self.subscribed) + .field("created_at", &self.created_at) + .field("updated_at", &self.updated_at) + .finish_non_exhaustive() + } +} + /// Request to create a campaign draft. #[derive(Debug, Deserialize)] pub struct CreateCampaign { @@ -239,4 +254,23 @@ mod test { assert_eq!(decoded.launched_at, Some(timestamp)); Ok(()) } + + #[test] + fn contact_debug_output_redacts_the_unsubscribe_token() { + let timestamp = Utc::now(); + let contact = Contact { + id: None, + email: "ada@example.com".into(), + first_name: Some("Ada".into()), + subscribed: true, + unsubscribe_token: "opaque-unsubscribe-token".into(), + created_at: timestamp, + updated_at: timestamp, + }; + + let debug = format!("{contact:?}"); + + assert!(debug.contains("ada@example.com")); + assert!(!debug.contains("opaque-unsubscribe-token")); + } } diff --git a/crates/marketing-server/src/repository.rs b/crates/marketing-server/src/repository.rs index 375a3ea..f17e639 100644 --- a/crates/marketing-server/src/repository.rs +++ b/crates/marketing-server/src/repository.rs @@ -79,20 +79,19 @@ impl MarketingRepository { Ok(campaign) } - /// Marks a campaign as launched and reports whether this request changed its state. + /// Atomically claims a draft campaign for launch. /// /// # Errors /// - /// Returns not-found or database errors. + /// Returns a validation error when the campaign is not a draft, or a + /// not-found or database error. pub async fn launch_campaign(&self, id: &str) -> Result { - let id = ObjectId::parse_str(id) - .map_err(|_| Error::Validation("campaign id is invalid".into()))?; - let now = Utc::now(); + let id = campaign_object_id(id)?; if let Some(campaign) = self .campaigns .find_one_and_update( - doc! { "_id": id, "status": { "$ne": "launched" } }, - doc! { "$set": { "status": "launched", "launched_at": mongodb::bson::DateTime::from_millis(now.timestamp_millis()) } }, + doc! { "_id": id, "status": "draft" }, + doc! { "$set": { "status": "launching" } }, ) .return_document(mongodb::options::ReturnDocument::After) .await? @@ -108,10 +107,41 @@ impl MarketingRepository { .find_one(doc! { "_id": id }) .await? .ok_or_else(|| Error::NotFound("campaign not found".into()))?; - Ok(LaunchOutcome { - campaign, - newly_launched: false, - }) + Err(Error::Validation(format!( + "campaign cannot be launched from {} state", + campaign_status_name(&campaign.status) + ))) + } + + /// Marks an already claimed campaign as launched after its handoff succeeds. + /// + /// # Errors + /// + /// Returns a validation error when the campaign is not being launched, or + /// a not-found or database error. + pub async fn complete_campaign_launch(&self, id: &str) -> Result { + let id = campaign_object_id(id)?; + if let Some(campaign) = self + .campaigns + .find_one_and_update( + doc! { "_id": id, "status": "launching" }, + doc! { "$set": { "status": "launched", "launched_at": mongodb::bson::DateTime::from_millis(Utc::now().timestamp_millis()) } }, + ) + .return_document(mongodb::options::ReturnDocument::After) + .await? + { + return Ok(campaign); + } + + let campaign = self + .campaigns + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| Error::NotFound("campaign not found".into()))?; + Err(Error::Validation(format!( + "campaign cannot complete launch from {} state", + campaign_status_name(&campaign.status) + ))) } /// Removes a contact from marketing delivery by token. @@ -133,6 +163,18 @@ fn inserted_object_id(inserted_id: &Bson) -> Result { }) } +fn campaign_object_id(id: &str) -> Result { + ObjectId::parse_str(id).map_err(|_| Error::Validation("campaign id is invalid".into())) +} + +fn campaign_status_name(status: &CampaignStatus) -> &'static str { + match status { + CampaignStatus::Draft => "draft", + CampaignStatus::Launching => "launching", + CampaignStatus::Launched => "launched", + } +} + fn new_contact(input: CreateContact, now: chrono::DateTime) -> Result { Ok(Contact { id: None, @@ -222,8 +264,8 @@ mod test { }; use super::{ - MarketingRepository, UNSUBSCRIBE_PLACEHOLDER, inserted_object_id, new_campaign, - new_contact, normalize_email, + MarketingRepository, UNSUBSCRIBE_PLACEHOLDER, campaign_status_name, inserted_object_id, + new_campaign, new_contact, normalize_email, }; use crate::{ Error, @@ -342,6 +384,16 @@ mod test { )); } + #[test] + fn names_every_campaign_lifecycle_state() { + assert_eq!(campaign_status_name(&CampaignStatus::Draft), "draft"); + assert_eq!( + campaign_status_name(&CampaignStatus::Launching), + "launching" + ); + assert_eq!(campaign_status_name(&CampaignStatus::Launched), "launched"); + } + #[tokio::test] async fn persists_contacts_campaigns_and_suppression_state() -> Result<(), Box> { @@ -389,19 +441,36 @@ mod test { repository.launch_campaign("not-an-id").await, Err(Error::Validation(_)) )); + assert!(matches!( + repository.complete_campaign_launch("not-an-id").await, + Err(Error::Validation(_)) + )); assert!(matches!( repository.launch_campaign(&ObjectId::new().to_hex()).await, Err(Error::NotFound(_)) )); - let launched = repository.launch_campaign(&campaign_id).await?; - assert!(launched.newly_launched); - assert_eq!(launched.campaign.status, CampaignStatus::Launched); - assert!(launched.campaign.launched_at.is_some()); + let launching = repository.launch_campaign(&campaign_id).await?; + assert!(launching.newly_launched); + assert_eq!(launching.campaign.status, CampaignStatus::Launching); + assert_eq!(launching.campaign.launched_at, None); + + assert!(matches!( + repository.launch_campaign(&campaign_id).await, + Err(Error::Validation(message)) if message == "campaign cannot be launched from launching state" + )); - let repeated_launch = repository.launch_campaign(&campaign_id).await?; - assert!(!repeated_launch.newly_launched); - assert_eq!(repeated_launch.campaign.status, CampaignStatus::Launched); + let launched = repository.complete_campaign_launch(&campaign_id).await?; + assert_eq!(launched.status, CampaignStatus::Launched); + assert!(launched.launched_at.is_some()); + assert!(matches!( + repository.complete_campaign_launch(&campaign_id).await, + Err(Error::Validation(message)) if message == "campaign cannot complete launch from launched state" + )); + assert!(matches!( + repository.launch_campaign(&campaign_id).await, + Err(Error::Validation(message)) if message == "campaign cannot be launched from launched state" + )); let unsubscribed = repository.unsubscribe(&contact.unsubscribe_token).await?; assert!(!unsubscribed.subscribed);