diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 13190d5d..fbd69229 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -30,6 +30,13 @@ jobs: ports: - 5433:5432 + # MailHog - Mock SMTP server for email verification tests + mailhog: + image: mailhog/mailhog:v1.0.1 + ports: + - 1025:1025 # SMTP + - 8025:8025 # Web UI / API + steps: - uses: actions/checkout@v4 @@ -66,3 +73,7 @@ jobs: cargo test --package e2e-tests --locked env: DATABASE_URL: postgres://postgres:postgres@localhost:5433/postgres + # MailHog configuration for email verification tests + MAILHOG_HOST: 127.0.0.1 + MAILHOG_SMTP_PORT: 1025 + MAILHOG_API_PORT: 8025 diff --git a/.github/workflows/web-e2e.yaml b/.github/workflows/web-e2e.yaml index c3e0cd03..6ab714bc 100644 --- a/.github/workflows/web-e2e.yaml +++ b/.github/workflows/web-e2e.yaml @@ -58,6 +58,24 @@ jobs: cd apps/zopp-web npx playwright install chromium --with-deps + - name: Start MailHog + run: | + docker run -d \ + --name mailhog \ + --network host \ + mailhog/mailhog:v1.0.1 + # Wait for MailHog to be ready + for i in {1..30}; do + if curl -sf http://localhost:8025/api/v2/messages > /dev/null 2>&1; then + echo "MailHog is ready" + exit 0 + fi + echo "Waiting for MailHog... ($i/30)" + sleep 1 + done + echo "ERROR: MailHog failed to become ready" + exit 1 + - name: Start zopp-server run: | ./target/debug/zopp-server serve --health-addr 0.0.0.0:8081 & @@ -70,6 +88,14 @@ jobs: echo "Waiting for server... ($i/30)" sleep 1 done + env: + # Email verification settings for MailHog + ZOPP_EMAIL_VERIFICATION_REQUIRED: "true" + ZOPP_EMAIL_PROVIDER: smtp + SMTP_HOST: 127.0.0.1 + SMTP_PORT: "1025" + SMTP_USE_TLS: "false" + ZOPP_EMAIL_FROM: test@zopp.local - name: Create Envoy config for CI run: | @@ -99,6 +125,10 @@ jobs: npx playwright test --reporter=list env: CI: true + # MailHog API for verification code retrieval + MAILHOG_API_URL: http://localhost:8025/api/v2 + # Database URL for creating test invites + DATABASE_URL: sqlite://${{ github.workspace }}/zopp.db - name: Upload test results uses: actions/upload-artifact@v4 @@ -112,4 +142,5 @@ jobs: if: always() run: | docker stop envoy || true + docker stop mailhog || true pkill -f zopp-server || true diff --git a/.gitignore b/.gitignore index dd616101..60cdc294 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ apps/zopp-web/test-results/ *.db-* # .sqlx/ directories are committed per-backend for offline compile checks +# Ignore root-level .sqlx (created by workspace-wide sqlx prepare, should not be committed) +/.sqlx # Documentation build output docs/build/ diff --git a/CLAUDE.md b/CLAUDE.md index 367401c8..ca75775c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,12 +287,32 @@ When creating PRs and working through CI: 1. **Create the PR**: Use `gh pr create` with a clear title and description 2. **Monitor CI**: Watch for CI check results - **Ignore docker builds** - they are slow and not required for most PRs - - Focus on: clippy, tests, fmt checks -3. **Wait for Cubic review**: Cubic is an AI code reviewer that runs automatically - - Address any comments Cubic makes - - Iterate until Cubic has no further comments - - Re-run CI after making changes -4. **Repeat until green**: Keep iterating until CI passes and Cubic is satisfied + - Focus on: clippy, tests, fmt checks, E2E tests, web-e2e tests +3. **Work with Cubic reviews**: Cubic is an AI code reviewer that does two types of reviews: + - **Incremental reviews**: Automatically triggered on each push, reviews only changed files + - **Full reviews**: Triggered by tagging `@cubic-dev-ai` in a PR comment + +### Cubic Review Workflow + +1. **Initial full review**: When PR is created, Cubic does a full review of all changes +2. **Address issues**: Fix any issues Cubic identifies, commit and push +3. **Incremental review**: Cubic automatically reviews only the new changes (not the whole PR) + - Check the CI check output: "AI review completed with X review. Y issues found across Z files (changes from recent commits)" + - If issues found in the incremental review, fix them and push again + - If 0 issues found, your fixes are good - but this only covers the recent changes +4. **Request full re-review**: Once incremental reviews pass, comment `@cubic-dev-ai Please do a full re-review of the PR.` +5. **Wait for full review**: The full re-review examines the entire PR again and may find new issues +6. **Iterate**: Repeat steps 2-5 until full review passes with 0 issues or only acceptable minor issues +7. **Merge**: Only merge after the full re-review completes successfully - never merge while it's pending + +### Reading Cubic Results + +- **CI Check**: Look at the "cubic-dev-ai / cubic · AI code reviewer" check for quick status +- **Review comments**: Cubic posts detailed issues as PR review comments +- **Addressed marker**: When you fix an issue, Cubic edits its comment to show "✅ Addressed in " - check if comments are marked as addressed rather than waiting for a new review +- **Outdated comments**: GitHub may mark comments as "outdated" if the code changed - these can often be ignored +- **Confidence score**: Higher is better (5/5 means high confidence the code is good) +- **Priority levels**: P1 (critical), P2 (important), P3 (minor) - always fix P1/P2 Example workflow: ```bash @@ -308,8 +328,12 @@ gh pr create --title "Add my feature" --body "Description..." # Monitor CI (ignore docker builds) gh pr checks -# If Cubic comments, address them and push again -# Repeat until all checks pass +# Check Cubic's initial review, fix issues, push +# Cubic does incremental review automatically +# When incremental shows 0 issues, request full re-review: +gh pr comment --body "@cubic-dev-ai Please do a full re-review of the PR." + +# Repeat until all checks pass and Cubic is satisfied ``` ## Important Notes diff --git a/Cargo.lock b/Cargo.lock index 1f7a4e14..fa4c704a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object", +] + [[package]] name = "argon2" version = "0.5.3" @@ -496,6 +505,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -923,12 +942,23 @@ dependencies = [ "kube", "nix", "paste", - "reqwest", + "regex", + "reqwest 0.12.28", + "serde", "serde_json", "sqlx", "tokio", ] +[[package]] +name = "ecow" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02" +dependencies = [ + "serde", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -985,6 +1015,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1109,6 +1155,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1222,6 +1274,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -1270,9 +1328,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1327,6 +1387,29 @@ dependencies = [ "web-sys", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.0", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "guardian" version = "1.3.0" @@ -1357,6 +1440,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -1366,7 +1453,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1374,6 +1461,11 @@ name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2237,6 +2329,34 @@ dependencies = [ "tachys", ] +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.3", +] + [[package]] name = "libc" version = "0.2.177" @@ -2358,6 +2478,17 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2495,6 +2626,21 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2561,6 +2707,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + [[package]] name = "oco_ref" version = "0.2.1" @@ -2854,6 +3009,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + [[package]] name = "potential_utf" version = "0.1.3" @@ -3017,6 +3178,16 @@ dependencies = [ "prost", ] +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.0" @@ -3037,6 +3208,21 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.41" @@ -3068,6 +3254,12 @@ dependencies = [ "syn", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -3133,6 +3325,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "reactive_graph" version = "0.1.8" @@ -3291,6 +3492,61 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resend-rs" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44432c279b970a2d9804fc3862cf3f09e7d9cfd15306b58b48621161dd52cac7" +dependencies = [ + "ecow", + "getrandom 0.3.4", + "governor", + "maybe-async", + "rand 0.9.2", + "reqwest 0.13.1", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "ring" version = "0.17.14" @@ -3897,6 +4153,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -4112,6 +4377,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -5073,6 +5351,16 @@ dependencies = [ "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 = "0.26.11" @@ -5101,6 +5389,22 @@ dependencies = [ "wasite", ] +[[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" @@ -5110,6 +5414,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" @@ -5675,6 +5985,7 @@ dependencies = [ "argon2", "chacha20poly1305", "getrandom 0.2.16", + "hex", "js-sys", "rand_core 0.6.4", "sha2", @@ -5761,7 +6072,7 @@ dependencies = [ "k8s-openapi", "kube", "prost", - "reqwest", + "reqwest 0.12.28", "schemars", "serde", "serde_json", @@ -5832,10 +6143,12 @@ dependencies = [ "ed25519-dalek", "futures", "hex", + "lettre", "prost", "rand 0.9.2", "rand_core 0.6.4", - "reqwest", + "reqwest 0.12.28", + "resend-rs", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 369966a8..52a30456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,11 +51,13 @@ hostname = "0.4.1" k8s-openapi = { version = "0.26.1", features = ["v1_30"] } keyring = { version = "3", default-features = false } kube = { version = "2.0.1", features = ["client", "rustls-tls"] } +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } mockall = "0.13" prost = "0.14" rand = "0.9.2" rand_core = { version = "0.6", features = ["getrandom"] } reqwest = "0.12" +resend-rs = "0.20" rpassword = "7" schemars = "1" serial_test = "3" diff --git a/apps/e2e-tests/Cargo.toml b/apps/e2e-tests/Cargo.toml index 08d04257..8b713ca5 100644 --- a/apps/e2e-tests/Cargo.toml +++ b/apps/e2e-tests/Cargo.toml @@ -6,7 +6,8 @@ publish = false [dependencies] tokio = { workspace = true, features = ["full"] } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sqlx = { workspace = true, features = ["postgres", "runtime-tokio-rustls"] } @@ -20,6 +21,9 @@ nix = { version = "0.29", features = ["signal"] } # For backend_test! macro name generation paste = "1.0" +# For email parsing in tests +regex = "1" + [[test]] name = "demo" path = "tests/demo.rs" @@ -64,3 +68,7 @@ path = "tests/counts.rs" [[test]] name = "secrets" path = "tests/secrets.rs" + +[[test]] +name = "email_verification" +path = "tests/email_verification.rs" diff --git a/apps/e2e-tests/tests/common/harness.rs b/apps/e2e-tests/tests/common/harness.rs index 22533a9a..82dc32bb 100644 --- a/apps/e2e-tests/tests/common/harness.rs +++ b/apps/e2e-tests/tests/common/harness.rs @@ -98,6 +98,13 @@ impl BackendConfig { // Test Harness // ═══════════════════════════════════════════════════════════════════════════ +/// Email capture backend - MailHog (via docker-compose.test.yaml) +#[derive(Clone)] +pub struct EmailBackend { + pub smtp_port: u16, + pub api_port: u16, +} + /// Test harness that manages server lifecycle and provides test utilities pub struct TestHarness { /// Server URL for CLI connections @@ -128,6 +135,10 @@ pub struct TestHarness { server_stdout_path: PathBuf, /// Server stderr log file path (for diagnostics on failure) server_stderr_path: PathBuf, + /// Whether email verification is enabled for this test + email_verification_enabled: bool, + /// Email capture backend (MailHog) + email_backend: Option, } impl TestHarness { @@ -210,6 +221,125 @@ impl TestHarness { pg_events_db_name, server_stdout_path, server_stderr_path, + email_verification_enabled: false, + email_backend: None, + }; + + // Start the server + harness.start_server().await?; + + Ok(harness) + } + + /// Create a new test harness with email verification enabled. + /// Requires MailHog to be running (via docker-compose.test.yaml). + /// Use `get_verification_code_from_email()` to retrieve codes from captured emails. + pub async fn new_with_verification( + test_name: &str, + config: BackendConfig, + ) -> Result> { + // Check postgres availability if required + if config.requires_postgres() { + check_postgres_available()?; + } + + // Check if MailHog is available (docker-compose.test.yaml) + let mailhog_smtp_port: u16 = std::env::var("MAILHOG_SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(1025); + let mailhog_api_port: u16 = std::env::var("MAILHOG_API_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8025); + + // Require MailHog to be running (via docker-compose.test.yaml) + let mailhog_client = super::mailhog::MailHogClient::from_env(); + if !mailhog_client.is_available().await { + return Err( + "MailHog not available. Start it with: docker compose -f docker/docker-compose.test.yaml up -d".into() + ); + } + + println!( + "Using MailHog for email capture (SMTP port: {}, API port: {})", + mailhog_smtp_port, mailhog_api_port + ); + // Clear any existing emails from previous test runs + // Fail fast if cleanup fails to avoid stale messages causing flaky tests + mailhog_client.clear().await?; + + let email_backend = Some(EmailBackend { + smtp_port: mailhog_smtp_port, + api_port: mailhog_api_port, + }); + + // Get binary paths from shared function + let (zopp_server_bin, zopp_bin, _) = super::get_binary_paths()?; + + // Create test directory + let test_id = std::process::id(); + let config_name = config.name(); + let test_dir = std::env::temp_dir().join(format!( + "zopp-e2e-{}-{}-{}", + test_name, config_name, test_id + )); + if test_dir.exists() { + fs::remove_dir_all(&test_dir)?; + } + fs::create_dir_all(&test_dir)?; + + // Find available ports + let port = find_available_port()?; + let health_port = find_available_port()?; + + // Setup database + let full_test_name = format!("{}_{}", test_name, config.name()); + let (database_url, pg_db_name) = + setup_database(&config.storage, &full_test_name, test_id).await?; + + // Setup events database if needed + let (events_database_url, pg_events_db_name) = match &config.events { + EventsBackend::Memory => (None, None), + EventsBackend::Postgres => match &config.storage { + StorageBackend::Postgres => (None, None), + StorageBackend::Sqlite => { + let events_db_name = format!( + "zopp_events_{}_{}_{}", + full_test_name.replace(['-', '+'], "_"), + test_id, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + % 10000 + ); + let events_url = create_postgres_db(&events_db_name).await?; + (Some(events_url), Some(events_db_name)) + } + }, + }; + + let server_stdout_path = test_dir.join("server_stdout.log"); + let server_stderr_path = test_dir.join("server_stderr.log"); + + let mut harness = Self { + server_url: format!("http://127.0.0.1:{}", port), + config, + test_dir, + zopp_bin, + zopp_server_bin, + server_process: None, + port, + health_port, + database_url, + events_database_url, + pg_db_name, + pg_events_db_name, + server_stdout_path, + server_stderr_path, + email_verification_enabled: true, + email_backend, }; // Start the server @@ -266,6 +396,22 @@ impl TestHarness { &health_addr, ]); + // Configure email verification if enabled + if self.email_verification_enabled { + let backend = self + .email_backend + .as_ref() + .ok_or("Email verification enabled but no email backend configured")?; + + let host = std::env::var("MAILHOG_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + cmd.env("ZOPP_EMAIL_VERIFICATION_REQUIRED", "true") + .env("ZOPP_EMAIL_PROVIDER", "smtp") + .env("SMTP_HOST", host) + .env("SMTP_PORT", backend.smtp_port.to_string()) + .env("SMTP_USE_TLS", "false") + .env("ZOPP_EMAIL_FROM", "test@example.com"); + } + // Configure events backend match &self.config.events { EventsBackend::Memory => { @@ -424,6 +570,113 @@ impl TestHarness { pub fn test_dir(&self) -> &PathBuf { &self.test_dir } + + /// Get the database URL (for direct queries in tests) + pub fn database_url(&self) -> &str { + &self.database_url + } + + /// Get the latest verification code for an email from MailHog. + /// Returns an error if MailHog is not configured or no email was found. + pub async fn get_verification_code_from_email( + &self, + email: &str, + ) -> Result> { + let backend = self + .email_backend + .as_ref() + .ok_or("No email backend enabled - use new_with_verification()")?; + + let host = std::env::var("MAILHOG_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let client = super::mailhog::MailHogClient::new(&host, backend.api_port); + + // Wait for email to arrive + if !client.wait_for_email(email, 5000).await? { + let stderr = self.read_server_log(&self.server_stderr_path.clone()); + return Err(format!( + "No emails captured by MailHog for: {}\n\ + Server stderr:\n{}", + email, + stderr.unwrap_or_else(|e| format!("", e)) + ) + .into()); + } + + let code = client.get_verification_code(email).await?; + code.ok_or_else(|| { + format!( + "No verification code found in MailHog emails for: {}", + email + ) + .into() + }) + } + + /// Check that a verification record exists for an email in the database. + /// Returns true if a verification record exists with a hashed code. + /// Note: We store hashes (zero-knowledge), so we can't retrieve the plaintext code. + pub fn has_verification_record(&self, email: &str) -> Result> { + if self.database_url.starts_with("postgres:") { + // PostgreSQL: use psql + // Escape single quotes for SQL safety + let escaped_email = email.replace('\'', "''"); + let output = Command::new("psql") + .arg(&self.database_url) + .arg("-t") // tuple only (no headers) + .arg("-A") // unaligned output + .arg("-c") + .arg(format!( + "SELECT COUNT(*) FROM email_verifications WHERE email = '{}'", + escaped_email + )) + .output()?; + + if !output.status.success() { + return Err( + format!("psql failed: {}", String::from_utf8_lossy(&output.stderr)).into(), + ); + } + + let count: i32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(count > 0) + } else { + // SQLite: extract path and use sqlite3 + let db_path = self + .database_url + .strip_prefix("sqlite://") + .or_else(|| self.database_url.strip_prefix("sqlite:")) + .map(|p| p.split('?').next().unwrap_or(p)) + .ok_or("Invalid SQLite URL")?; + + // Escape single quotes for SQL safety + let escaped_email = email.replace('\'', "''"); + let output = Command::new("sqlite3") + .arg(db_path) + .arg("-noheader") + .arg(format!( + "SELECT COUNT(*) FROM email_verifications WHERE email = '{}';", + escaped_email + )) + .output()?; + + if !output.status.success() { + return Err(format!( + "sqlite3 failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + let count: i32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(count > 0) + } + } } impl Drop for TestHarness { @@ -524,6 +777,26 @@ impl TestUser { let result = self.exec(&["join", invite, email, "--principal", principal]); result.success_or_err("join") } + + /// Join the server with an invite and verification code + pub fn join_with_verification( + &self, + invite: &str, + email: &str, + principal: &str, + verification_code: &str, + ) -> Result<(), Box> { + let result = self.exec(&[ + "join", + invite, + email, + "--principal", + principal, + "--verification-code", + verification_code, + ]); + result.success_or_err("join with verification") + } } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/e2e-tests/tests/common/mailhog.rs b/apps/e2e-tests/tests/common/mailhog.rs new file mode 100644 index 00000000..bae4fee0 --- /dev/null +++ b/apps/e2e-tests/tests/common/mailhog.rs @@ -0,0 +1,175 @@ +//! MailHog client for E2E testing email verification. +//! +//! MailHog is a mock SMTP server with an HTTP API for retrieving captured emails. +//! This module provides a client to interact with MailHog's API. +//! +//! Start MailHog: docker compose -f docker/docker-compose.test.yaml up -d + +use regex::Regex; +use serde::Deserialize; + +/// MailHog API response for messages +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // Fields needed for deserialization but not all are read +pub struct MailHogMessages { + pub total: u32, + pub count: u32, + pub start: u32, + pub items: Vec, +} + +/// A single message from MailHog +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct MailHogMessage { + #[serde(rename = "ID")] + pub id: String, + #[serde(rename = "From")] + pub from: MailHogAddress, + #[serde(rename = "To")] + pub to: Vec, + #[serde(rename = "Content")] + pub content: MailHogContent, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct MailHogAddress { + #[serde(rename = "Mailbox")] + pub mailbox: String, + #[serde(rename = "Domain")] + pub domain: String, +} + +impl MailHogAddress { + pub fn email(&self) -> String { + format!("{}@{}", self.mailbox, self.domain) + } +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct MailHogContent { + #[serde(rename = "Headers")] + pub headers: std::collections::HashMap>, + #[serde(rename = "Body")] + pub body: String, +} + +/// MailHog client for retrieving captured emails +pub struct MailHogClient { + api_url: String, + client: reqwest::Client, +} + +impl MailHogClient { + /// Create a new MailHog client + pub fn new(host: &str, port: u16) -> Self { + Self { + api_url: format!("http://{}:{}/api/v2", host, port), + client: reqwest::Client::new(), + } + } + + /// Create from environment variables or defaults + /// Uses MAILHOG_HOST (default: 127.0.0.1) and MAILHOG_API_PORT (default: 8025) + pub fn from_env() -> Self { + let host = std::env::var("MAILHOG_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port: u16 = std::env::var("MAILHOG_API_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8025); + Self::new(&host, port) + } + + /// Check if MailHog is available + pub async fn is_available(&self) -> bool { + self.client + .get(format!("{}/messages", self.api_url)) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } + + /// Get all messages + pub async fn get_messages(&self) -> Result> { + let response = self + .client + .get(format!("{}/messages", self.api_url)) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("MailHog API error: {}", response.status()).into()); + } + + Ok(response.json().await?) + } + + /// Get the latest email sent to a specific address + pub async fn get_email_for( + &self, + to_email: &str, + ) -> Result, Box> { + let messages = self.get_messages().await?; + + Ok(messages.items.into_iter().rev().find(|msg| { + msg.to + .iter() + .any(|addr| addr.email().to_lowercase() == to_email.to_lowercase()) + })) + } + + /// Extract verification code from the latest email to an address. + /// Looks for a 6-digit code in the email body. + pub async fn get_verification_code( + &self, + to_email: &str, + ) -> Result, Box> { + let email = self.get_email_for(to_email).await?; + + Ok(email.and_then(|msg| { + let re = Regex::new(r"\b(\d{6})\b").ok()?; + re.captures(&msg.content.body) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + })) + } + + /// Clear all messages + pub async fn clear(&self) -> Result<(), Box> { + // MailHog delete endpoint is on v1, not v2 + let delete_url = self.api_url.replace("/api/v2", "/api/v1/messages"); + self.client.delete(&delete_url).send().await?; + Ok(()) + } + + /// Wait for an email to arrive (with timeout) + pub async fn wait_for_email( + &self, + to_email: &str, + timeout_ms: u64, + ) -> Result> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(timeout_ms); + + while start.elapsed() < timeout { + if self.get_email_for(to_email).await?.is_some() { + return Ok(true); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + Ok(false) + } +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn test_mailhog_client_creation() { + let client = super::MailHogClient::new("localhost", 8025); + assert!(client.api_url.contains("localhost:8025")); + } +} diff --git a/apps/e2e-tests/tests/common/mod.rs b/apps/e2e-tests/tests/common/mod.rs index 9f69a048..ef8c80e8 100644 --- a/apps/e2e-tests/tests/common/mod.rs +++ b/apps/e2e-tests/tests/common/mod.rs @@ -1,6 +1,7 @@ //! Common utilities for E2E tests. pub mod harness; +pub mod mailhog; pub mod utils; // Re-export for tests that use the new infrastructure @@ -8,4 +9,6 @@ pub mod utils; #[allow(unused_imports)] pub use harness::*; #[allow(unused_imports)] +pub use mailhog::*; +#[allow(unused_imports)] pub use utils::*; diff --git a/apps/e2e-tests/tests/email_verification.rs b/apps/e2e-tests/tests/email_verification.rs new file mode 100644 index 00000000..feeaa91d --- /dev/null +++ b/apps/e2e-tests/tests/email_verification.rs @@ -0,0 +1,174 @@ +//! Email Verification E2E tests +//! +//! Tests the email verification flow for new principals. +//! +//! These tests use a mock SMTP server to capture verification emails, +//! allowing us to test the complete flow: +//! 1. Start join (triggers verification email) +//! 2. Extract verification code from captured email +//! 3. Complete join with valid verification code + +#[macro_use] +mod common; + +use common::{BackendConfig, TestHarness}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Email Verification Tests +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn test_email_verification_harness_sqlite() -> Result<(), Box> { + run_test_verification_harness(BackendConfig::sqlite_memory()).await +} + +#[tokio::test] +async fn test_email_verification_invalid_code_sqlite() -> Result<(), Box> { + run_test_invalid_verification_code(BackendConfig::sqlite_memory()).await +} + +#[tokio::test] +async fn test_email_verification_full_flow_sqlite() -> Result<(), Box> { + run_test_full_verification_flow(BackendConfig::sqlite_memory()).await +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Implementations +// ═══════════════════════════════════════════════════════════════════════════ + +/// Test that the verification-enabled harness works and codes are created +async fn run_test_verification_harness( + config: BackendConfig, +) -> Result<(), Box> { + println!(" Test: Verification-enabled harness starts correctly..."); + + // Create harness with email verification enabled + let harness = TestHarness::new_with_verification("verify_harness", config).await?; + + // Verify server is running + let invite = harness.create_server_invite()?; + assert!( + invite.starts_with("inv_"), + "Server should create valid invites" + ); + + println!(" Harness with verification enabled started successfully"); + println!("test_email_verification_harness PASSED"); + Ok(()) +} + +/// Test that join with invalid verification code fails appropriately +async fn run_test_invalid_verification_code( + config: BackendConfig, +) -> Result<(), Box> { + let harness = TestHarness::new_with_verification("verify_invalid", config).await?; + let invite = harness.create_server_invite()?; + let alice = harness.create_user("alice"); + + println!(" Test 1: Join with invalid verification code should fail..."); + + let result = alice.exec(&[ + "join", + &invite, + &alice.email(), + "--principal", + &alice.principal(), + "--verification-code", + "XXXXXX", // Non-numeric code - guaranteed to be invalid + ]); + + // Join should fail because verification code is invalid + assert!(result.failed(), "Join with invalid code should fail"); + + let stderr = result.stderr(); + let stdout = result.stdout(); + let output = format!("{}{}", stdout, stderr); + + assert!( + output.contains("verification") + || output.contains("Verification") + || output.contains("Invalid") + || output.contains("invalid"), + "Error should mention verification: stdout='{}' stderr='{}'", + stdout, + stderr + ); + + println!(" Test 2: Verification email should have been sent..."); + + // The join attempt should have sent a verification email + let code = harness + .get_verification_code_from_email(&alice.email()) + .await?; + assert_eq!(code.len(), 6, "Verification code should be 6 digits"); + assert!( + code.chars().all(|c| c.is_ascii_digit()), + "Verification code should be all digits: {}", + code + ); + println!(" Retrieved verification code from email: {}", code); + + println!( + " Test 3: Database should have a verification record (hash stored, not plaintext)..." + ); + + let has_record = harness.has_verification_record(&alice.email())?; + assert!( + has_record, + "Database should have a verification record for the email" + ); + println!(" Verification record exists in database (code hash stored)"); + + println!("test_email_verification_invalid_code PASSED"); + Ok(()) +} + +/// Test the complete verification flow: request code, receive email, verify +async fn run_test_full_verification_flow( + config: BackendConfig, +) -> Result<(), Box> { + let harness = TestHarness::new_with_verification("verify_full", config).await?; + let invite = harness.create_server_invite()?; + let bob = harness.create_user("bob"); + + println!(" Test 1: First join attempt triggers verification email..."); + + // First attempt with invalid code to trigger email sending + let result = bob.exec(&[ + "join", + &invite, + &bob.email(), + "--principal", + &bob.principal(), + "--verification-code", + "XXXXXX", // Non-numeric code - guaranteed to be invalid + ]); + assert!(result.failed(), "First join should fail with wrong code"); + + // Get the verification code from the captured email + let code = harness + .get_verification_code_from_email(&bob.email()) + .await?; + println!(" Received verification code: {}", code); + + println!(" Test 2: Join with correct verification code should succeed..."); + + // Now join with the correct code + bob.join_with_verification(&invite, &bob.email(), &bob.principal(), &code)?; + println!(" Join succeeded!"); + + println!(" Test 3: User should be able to use the CLI after verification..."); + + // Verify the user can now create workspaces + let result = bob.exec(&["workspace", "create", "test-workspace"]); + let output = result.success()?; + assert!( + output.contains("test-workspace") || output.contains("Created"), + "Should be able to create workspace after verification: {}", + output + ); + println!(" Created workspace successfully"); + + println!("test_email_verification_full_flow PASSED"); + Ok(()) +} diff --git a/apps/e2e-tests/tests/k8s.rs b/apps/e2e-tests/tests/k8s.rs index aa063953..ef0da167 100644 --- a/apps/e2e-tests/tests/k8s.rs +++ b/apps/e2e-tests/tests/k8s.rs @@ -914,9 +914,9 @@ async fn operator_sync() -> Result<(), Box> { return Err("Operator health check failed after 20 attempts".into()); } - // Wait for initial sync + // Wait for initial sync (increased timeout for CI) println!("⏳ Waiting for operator to sync..."); - sleep(Duration::from_secs(3)).await; + sleep(Duration::from_secs(10)).await; // Verify secrets were synced (check values only, not labels for annotated secrets) match verify_k8s_secret_values("app-secrets", &initial_secrets).await { diff --git a/apps/zopp-cli/src/cli.rs b/apps/zopp-cli/src/cli.rs index f88f5b7a..022e61b2 100644 --- a/apps/zopp-cli/src/cli.rs +++ b/apps/zopp-cli/src/cli.rs @@ -35,6 +35,10 @@ pub enum Command { /// Principal name (optional, defaults to hostname) #[arg(long)] principal: Option, + + /// Verification code (for non-interactive use when email verification is required) + #[arg(long)] + verification_code: Option, }, /// Workspace commands Workspace { diff --git a/apps/zopp-cli/src/commands/join.rs b/apps/zopp-cli/src/commands/join.rs index 5087e1b4..338d06b9 100644 --- a/apps/zopp-cli/src/commands/join.rs +++ b/apps/zopp-cli/src/commands/join.rs @@ -1,7 +1,20 @@ use crate::config::{save_config, store_principal_secrets, CliConfig, PrincipalConfig}; use crate::grpc::connect; use ed25519_dalek::SigningKey; -use zopp_proto::JoinRequest; +use std::io::{self, Write}; +use zopp_proto::{JoinRequest, ResendVerificationRequest, VerifyEmailRequest}; + +/// Principal key material generated during join, kept in memory until verification +struct PrincipalKeys { + principal_name: String, + signing_key: SigningKey, + x25519_keypair: zopp_crypto::Keypair, + public_key: Vec, + x25519_public_key: Vec, + ephemeral_pub: Vec, + kek_wrapped: Vec, + kek_nonce: Vec, +} pub async fn cmd_join( server: &str, @@ -10,6 +23,7 @@ pub async fn cmd_join( email: &str, principal_name: Option<&str>, use_file_storage: bool, + verification_code: Option<&str>, ) -> Result<(), Box> { // Use provided principal name or default to hostname let principal_name = match principal_name { @@ -95,6 +109,18 @@ pub async fn cmd_join( (vec![], vec![], vec![]) }; + // Store principal key material for use during verification + let keys = PrincipalKeys { + principal_name: principal_name.clone(), + signing_key, + x25519_keypair, + public_key: public_key.clone(), + x25519_public_key: x25519_public_bytes.clone(), + ephemeral_pub: ephemeral_pub.clone(), + kek_wrapped: kek_wrapped.clone(), + kek_nonce: kek_nonce.clone(), + }; + let response = client .join(JoinRequest { invite_token: server_token, @@ -109,18 +135,72 @@ pub async fn cmd_join( .await? .into_inner(); - println!("✓ Joined successfully!\n"); - println!("User ID: {}", response.user_id); - println!("Principal ID: {}", response.principal_id); + // Determine final principal_id and workspaces based on verification flow + let (final_principal_id, final_workspaces, final_user_id) = if response.verification_required { + if let Some(code) = verification_code { + // Non-interactive: use provided verification code + let verify_response = client + .verify_email(VerifyEmailRequest { + email: email.to_string(), + code: code.to_string(), + principal_name: keys.principal_name.clone(), + public_key: keys.public_key.clone(), + x25519_public_key: keys.x25519_public_key.clone(), + ephemeral_pub: keys.ephemeral_pub.clone(), + kek_wrapped: keys.kek_wrapped.clone(), + kek_nonce: keys.kek_nonce.clone(), + }) + .await? + .into_inner(); + + if !verify_response.success { + return Err( + format!("Email verification failed: {}", verify_response.message).into(), + ); + } + println!("✓ Email verified successfully!\n"); + ( + verify_response.principal_id, + verify_response.workspaces, + verify_response.user_id, + ) + } else { + // Interactive: prompt for code + println!("📧 Email verification required.\n"); + println!("A verification code has been sent to: {}", email); + println!("The code is valid for 15 minutes.\n"); + + let verify_response = verify_email_flow(&mut client, email, &keys).await?; + match verify_response { + Some(resp) => { + println!("✓ Email verified successfully!\n"); + (resp.principal_id, resp.workspaces, resp.user_id) + } + None => { + return Err("Email verification failed. Please try joining again.".into()); + } + } + } + } else { + println!("✓ Joined successfully!\n"); + // No verification needed, principal_id is in the join response + let principal_id = response + .principal_id + .ok_or("Missing principal_id in response")?; + (principal_id, response.workspaces, response.user_id) + }; + + println!("User ID: {}", final_user_id); + println!("Principal ID: {}", final_principal_id); println!("Principal: {}", principal_name); println!("\nWorkspaces:"); - for ws in &response.workspaces { + for ws in &final_workspaces { println!(" - {} ({})", ws.name, ws.id); } // Store secrets - let ed25519_private_hex = hex::encode(signing_key.to_bytes()); - let x25519_private_hex = hex::encode(x25519_keypair.secret_key_bytes()); + let ed25519_private_hex = hex::encode(keys.signing_key.to_bytes()); + let x25519_private_hex = hex::encode(keys.x25519_keypair.secret_key_bytes()); // Determine where to store private keys let (private_key_for_config, x25519_private_for_config) = if use_file_storage { @@ -132,7 +212,7 @@ pub async fn cmd_join( } else { // Store in keychain store_principal_secrets( - &response.principal_id, + &final_principal_id, &ed25519_private_hex, Some(&x25519_private_hex), )?; @@ -142,14 +222,14 @@ pub async fn cmd_join( // Save config let config = CliConfig { principals: vec![PrincipalConfig { - id: response.principal_id, + id: final_principal_id, name: principal_name.clone(), - user_id: Some(response.user_id), + user_id: Some(final_user_id), email: Some(email.to_string()), private_key: private_key_for_config, - public_key: hex::encode(verifying_key.to_bytes()), + public_key: hex::encode(keys.signing_key.verifying_key().to_bytes()), x25519_private_key: x25519_private_for_config, - x25519_public_key: Some(hex::encode(x25519_keypair.public_key_bytes())), + x25519_public_key: Some(hex::encode(keys.x25519_keypair.public_key_bytes())), }], current_principal: Some(principal_name), use_file_storage, @@ -179,3 +259,102 @@ pub async fn cmd_join( Ok(()) } + +/// Successful verification result +struct VerifySuccess { + principal_id: String, + user_id: String, + workspaces: Vec, +} + +/// Handle email verification flow with user input. +/// Returns Some(VerifySuccess) if verification succeeded, None otherwise. +async fn verify_email_flow( + client: &mut zopp_proto::zopp_service_client::ZoppServiceClient, + email: &str, + keys: &PrincipalKeys, +) -> Result, Box> { + // Track if user must resend before trying another code (after server lockout) + let mut must_resend = false; + + loop { + // If user must resend, don't allow entering codes until they do + if must_resend { + print!("Enter 'r' to request a new code (or 'q' to quit): "); + } else { + print!("Enter verification code (or 'r' to resend, 'q' to quit): "); + } + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim(); + + // Handle special commands + if input.eq_ignore_ascii_case("q") || input.eq_ignore_ascii_case("quit") { + return Ok(None); + } + + if input.eq_ignore_ascii_case("r") || input.eq_ignore_ascii_case("resend") { + // Request new code + let resend_response = client + .resend_verification(ResendVerificationRequest { + email: email.to_string(), + }) + .await? + .into_inner(); + + if resend_response.success { + println!("✓ New verification code sent to {}\n", email); + must_resend = false; // Allow attempts again after successful resend + continue; + } else { + println!("⚠ {}\n", resend_response.message); + continue; + } + } + + // If user must resend but didn't, remind them + if must_resend { + println!("⚠ Please request a new code first (enter 'r').\n"); + continue; + } + + // Validate code format (6 digits) + if input.len() != 6 || !input.chars().all(|c| c.is_ascii_digit()) { + println!("⚠ Invalid code format. Please enter the 6-digit code from your email.\n"); + continue; + } + + // Verify the code, sending principal data to create the principal on success + let verify_response = client + .verify_email(VerifyEmailRequest { + email: email.to_string(), + code: input.to_string(), + principal_name: keys.principal_name.clone(), + public_key: keys.public_key.clone(), + x25519_public_key: keys.x25519_public_key.clone(), + ephemeral_pub: keys.ephemeral_pub.clone(), + kek_wrapped: keys.kek_wrapped.clone(), + kek_nonce: keys.kek_nonce.clone(), + }) + .await? + .into_inner(); + + if verify_response.success { + return Ok(Some(VerifySuccess { + principal_id: verify_response.principal_id, + user_id: verify_response.user_id, + workspaces: verify_response.workspaces, + })); + } + + println!("⚠ {}\n", verify_response.message); + + // Check if server says no attempts remaining (server deleted the verification) + if verify_response.attempts_remaining <= 0 { + println!("You must request a new verification code.\n"); + must_resend = true; + } + } +} diff --git a/apps/zopp-cli/src/commands/principal.rs b/apps/zopp-cli/src/commands/principal.rs index b0ff119d..756d0270 100644 --- a/apps/zopp-cli/src/commands/principal.rs +++ b/apps/zopp-cli/src/commands/principal.rs @@ -899,20 +899,8 @@ fn derive_export_key( passphrase: &str, salt: &[u8], ) -> Result, Box> { - use argon2::{Algorithm, Argon2, Params, Version}; - - // Use lighter params for export (still secure, but faster) - // 64 MiB memory, 3 iterations - let params = - Params::new(64 * 1024, 3, 1, Some(32)).map_err(|e| format!("Argon2 params: {}", e))?; - let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - - let mut key = Zeroizing::new([0u8; 32]); - argon2 - .hash_password_into(passphrase.as_bytes(), salt, &mut *key) - .map_err(|e| format!("Key derivation failed: {}", e))?; - - Ok(key) + zopp_crypto::argon2_hash_raw(passphrase.as_bytes(), salt) + .map_err(|e| format!("Key derivation failed: {}", e).into()) } /// Compute Argon2id verification hash for passphrase @@ -921,18 +909,6 @@ fn compute_verification_hash( passphrase: &str, verification_salt: &[u8], ) -> Result> { - use argon2::{Algorithm, Argon2, Params, Version}; - - // Same Argon2id params as derive_export_key for consistency - // 64 MiB memory, 3 iterations - provides strong brute-force resistance - let params = - Params::new(64 * 1024, 3, 1, Some(32)).map_err(|e| format!("Argon2 params: {}", e))?; - let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - - let mut hash = Zeroizing::new([0u8; 32]); - argon2 - .hash_password_into(passphrase.as_bytes(), verification_salt, &mut *hash) - .map_err(|e| format!("Hash computation failed: {}", e))?; - - Ok(hex::encode(*hash)) + zopp_crypto::argon2_hash(passphrase.as_bytes(), verification_salt) + .map_err(|e| format!("Hash computation failed: {}", e).into()) } diff --git a/apps/zopp-cli/src/main.rs b/apps/zopp-cli/src/main.rs index 64717e3b..f321c73e 100644 --- a/apps/zopp-cli/src/main.rs +++ b/apps/zopp-cli/src/main.rs @@ -25,6 +25,7 @@ async fn main() -> Result<(), Box> { token, email, principal, + verification_code, } => { cmd_join( &cli.server, @@ -33,6 +34,7 @@ async fn main() -> Result<(), Box> { &email, principal.as_deref(), cli.use_file_storage, + verification_code.as_deref(), ) .await?; } diff --git a/apps/zopp-server/Cargo.toml b/apps/zopp-server/Cargo.toml index 883a4d46..02fd76b6 100644 --- a/apps/zopp-server/Cargo.toml +++ b/apps/zopp-server/Cargo.toml @@ -39,6 +39,15 @@ hex = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } +# Email providers (optional) +resend-rs = { workspace = true, optional = true } +lettre = { workspace = true, optional = true } + +[features] +default = ["email-smtp"] +email-resend = ["resend-rs"] +email-smtp = ["lettre"] + [dev-dependencies] tempfile = { workspace = true } reqwest = { workspace = true } diff --git a/apps/zopp-server/src/backend.rs b/apps/zopp-server/src/backend.rs index 760cc18e..003a3203 100644 --- a/apps/zopp-server/src/backend.rs +++ b/apps/zopp-server/src/backend.rs @@ -100,6 +100,13 @@ impl Store for StoreBackend { } } + async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { + match self { + StoreBackend::Sqlite(s) => s.consume_invite(token).await, + StoreBackend::Postgres(s) => s.consume_invite(token).await, + } + } + async fn create_principal_export( &self, params: &CreatePrincipalExportParams, @@ -1082,6 +1089,55 @@ impl Store for StoreBackend { } } } + + // Email verification + async fn create_email_verification( + &self, + params: &CreateEmailVerificationParams, + ) -> Result { + match self { + StoreBackend::Sqlite(s) => s.create_email_verification(params).await, + StoreBackend::Postgres(s) => s.create_email_verification(params).await, + } + } + + async fn get_email_verification(&self, email: &str) -> Result { + match self { + StoreBackend::Sqlite(s) => s.get_email_verification(email).await, + StoreBackend::Postgres(s) => s.get_email_verification(email).await, + } + } + + async fn increment_email_verification_attempts( + &self, + id: &EmailVerificationId, + ) -> Result { + match self { + StoreBackend::Sqlite(s) => s.increment_email_verification_attempts(id).await, + StoreBackend::Postgres(s) => s.increment_email_verification_attempts(id).await, + } + } + + async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> { + match self { + StoreBackend::Sqlite(s) => s.delete_email_verification(id).await, + StoreBackend::Postgres(s) => s.delete_email_verification(id).await, + } + } + + async fn cleanup_expired_email_verifications(&self) -> Result { + match self { + StoreBackend::Sqlite(s) => s.cleanup_expired_email_verifications().await, + StoreBackend::Postgres(s) => s.cleanup_expired_email_verifications().await, + } + } + + async fn mark_user_verified(&self, user_id: &UserId) -> Result<(), StoreError> { + match self { + StoreBackend::Sqlite(s) => s.mark_user_verified(user_id).await, + StoreBackend::Postgres(s) => s.mark_user_verified(user_id).await, + } + } } #[async_trait::async_trait] diff --git a/apps/zopp-server/src/config.rs b/apps/zopp-server/src/config.rs new file mode 100644 index 00000000..a9ea0196 --- /dev/null +++ b/apps/zopp-server/src/config.rs @@ -0,0 +1,424 @@ +//! Server configuration module for email verification. +//! +//! Supports configuration via environment variables: +//! +//! ```bash +//! # Core settings +//! ZOPP_EMAIL_VERIFICATION_REQUIRED=true # enabled by default +//! +//! # Provider: Resend +//! ZOPP_EMAIL_PROVIDER=resend +//! RESEND_API_KEY=re_... +//! +//! # Provider: SMTP +//! ZOPP_EMAIL_PROVIDER=smtp +//! SMTP_HOST=smtp.gmail.com +//! SMTP_PORT=587 +//! SMTP_USERNAME=user@example.com +//! SMTP_PASSWORD=app_password +//! SMTP_USE_TLS=true +//! +//! # Sender config +//! ZOPP_EMAIL_FROM=noreply@zopp.dev +//! ZOPP_EMAIL_FROM_NAME="Zopp Security" +//! ``` + +use std::env; +use thiserror::Error; + +/// Server configuration +#[derive(Debug, Clone, Default)] +pub struct ServerConfig { + pub email: Option, +} + +/// Email configuration for verification +#[derive(Debug, Clone)] +pub struct EmailConfig { + /// Whether email verification is required for new principals + pub verification_required: bool, + /// Email provider configuration + pub provider: EmailProviderConfig, + /// From email address + pub from_address: String, + /// Optional from name + pub from_name: Option, +} + +/// Email provider configuration +#[derive(Debug, Clone)] +pub enum EmailProviderConfig { + /// Resend email provider + Resend { + /// Resend API key + #[allow(dead_code)] // Used when email-resend feature is enabled + api_key: String, + }, + /// SMTP email provider + Smtp { + /// SMTP host + host: String, + /// SMTP port + port: u16, + /// Optional username + username: Option, + /// Optional password + password: Option, + /// Whether to use TLS + use_tls: bool, + }, +} + +/// Configuration errors +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("Email verification is enabled but no email provider is configured")] + VerificationEnabledWithoutProvider, + + #[error("Invalid email provider: {0}. Expected 'resend' or 'smtp'")] + InvalidProvider(String), + + #[error("Missing required environment variable: {0}")] + MissingEnvVar(String), + + #[error("Invalid port number: {0}")] + InvalidPort(String), + + #[error("Missing from address: ZOPP_EMAIL_FROM is required when email is configured")] + MissingFromAddress, + + #[error("SMTP provider requires SMTP_HOST")] + SmtpMissingHost, +} + +impl ServerConfig { + /// Load configuration from environment variables + pub fn from_env() -> Result { + let verification_required = env::var("ZOPP_EMAIL_VERIFICATION_REQUIRED") + .map(|v| v.to_lowercase() == "true" || v == "1") + .unwrap_or(true); // Enabled by default + + let provider_type = env::var("ZOPP_EMAIL_PROVIDER").ok(); + + // If no provider is configured + if provider_type.is_none() { + if verification_required { + // Check if there's an explicit setting for verification + if env::var("ZOPP_EMAIL_VERIFICATION_REQUIRED").is_ok() { + return Err(ConfigError::VerificationEnabledWithoutProvider); + } + // If verification_required is just the default, silently disable email + return Ok(Self { email: None }); + } + return Ok(Self { email: None }); + } + + let provider_type = provider_type.unwrap(); + let provider = match provider_type.to_lowercase().as_str() { + "resend" => { + let api_key = env::var("RESEND_API_KEY") + .map_err(|_| ConfigError::MissingEnvVar("RESEND_API_KEY".to_string()))?; + EmailProviderConfig::Resend { api_key } + } + "smtp" => { + let host = env::var("SMTP_HOST").map_err(|_| ConfigError::SmtpMissingHost)?; + let port = env::var("SMTP_PORT") + .unwrap_or_else(|_| "587".to_string()) + .parse::() + .map_err(|_| { + ConfigError::InvalidPort( + env::var("SMTP_PORT").unwrap_or_else(|_| "invalid".to_string()), + ) + })?; + let username = env::var("SMTP_USERNAME").ok(); + let password = env::var("SMTP_PASSWORD").ok(); + let use_tls = env::var("SMTP_USE_TLS") + .map(|v| v.to_lowercase() == "true" || v == "1") + .unwrap_or(true); // TLS by default + + EmailProviderConfig::Smtp { + host, + port, + username, + password, + use_tls, + } + } + other => return Err(ConfigError::InvalidProvider(other.to_string())), + }; + + let from_address = + env::var("ZOPP_EMAIL_FROM").map_err(|_| ConfigError::MissingFromAddress)?; + let from_name = env::var("ZOPP_EMAIL_FROM_NAME").ok(); + + Ok(Self { + email: Some(EmailConfig { + verification_required, + provider, + from_address, + from_name, + }), + }) + } + + /// Check if email verification is required + pub fn is_verification_required(&self) -> bool { + self.email + .as_ref() + .map(|e| e.verification_required) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::sync::Mutex; + + // Mutex to serialize tests that modify environment variables + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + // All env vars we touch in tests - cleared before each test + const ENV_VARS: &[&str] = &[ + "ZOPP_EMAIL_VERIFICATION_REQUIRED", + "ZOPP_EMAIL_PROVIDER", + "RESEND_API_KEY", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USERNAME", + "SMTP_PASSWORD", + "SMTP_USE_TLS", + "ZOPP_EMAIL_FROM", + "ZOPP_EMAIL_FROM_NAME", + ]; + + // Helper to clean up env vars - holds mutex lock + struct EnvGuard<'a> { + _lock: std::sync::MutexGuard<'a, ()>, + } + + impl<'a> EnvGuard<'a> { + fn new() -> Self { + let lock = ENV_MUTEX.lock().unwrap(); + // Clear all env vars at start + for var in ENV_VARS { + env::remove_var(var); + } + Self { _lock: lock } + } + + fn set(&self, key: &str, value: &str) { + env::set_var(key, value); + } + + #[allow(dead_code)] + fn remove(&self, key: &str) { + env::remove_var(key); + } + } + + impl<'a> Drop for EnvGuard<'a> { + fn drop(&mut self) { + // Clear all env vars on drop + for var in ENV_VARS { + env::remove_var(var); + } + } + } + + #[test] + fn test_default_config_no_email() { + let _guard = EnvGuard::new(); + // No env vars set, so email should be None + + let config = ServerConfig::from_env().unwrap(); + assert!(config.email.is_none()); + assert!(!config.is_verification_required()); + } + + #[test] + fn test_verification_enabled_without_provider_explicit() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_VERIFICATION_REQUIRED", "true"); + guard.remove("ZOPP_EMAIL_PROVIDER"); + + let result = ServerConfig::from_env(); + assert!(matches!( + result, + Err(ConfigError::VerificationEnabledWithoutProvider) + )); + } + + #[test] + fn test_resend_provider_config() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "resend"); + guard.set("RESEND_API_KEY", "re_test_key"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + guard.set("ZOPP_EMAIL_FROM_NAME", "Test Sender"); + guard.set("ZOPP_EMAIL_VERIFICATION_REQUIRED", "true"); + + let config = ServerConfig::from_env().unwrap(); + let email = config.email.unwrap(); + assert!(email.verification_required); + assert_eq!(email.from_address, "test@example.com"); + assert_eq!(email.from_name, Some("Test Sender".to_string())); + + match email.provider { + EmailProviderConfig::Resend { api_key } => { + assert_eq!(api_key, "re_test_key"); + } + _ => panic!("Expected Resend provider"), + } + } + + #[test] + fn test_resend_missing_api_key() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "resend"); + guard.remove("RESEND_API_KEY"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + + let result = ServerConfig::from_env(); + assert!(matches!(result, Err(ConfigError::MissingEnvVar(_)))); + } + + #[test] + fn test_smtp_provider_config() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "smtp"); + guard.set("SMTP_HOST", "smtp.example.com"); + guard.set("SMTP_PORT", "465"); + guard.set("SMTP_USERNAME", "user@example.com"); + guard.set("SMTP_PASSWORD", "secret"); + guard.set("SMTP_USE_TLS", "true"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + guard.set("ZOPP_EMAIL_VERIFICATION_REQUIRED", "true"); + + let config = ServerConfig::from_env().unwrap(); + let email = config.email.unwrap(); + + match email.provider { + EmailProviderConfig::Smtp { + host, + port, + username, + password, + use_tls, + } => { + assert_eq!(host, "smtp.example.com"); + assert_eq!(port, 465); + assert_eq!(username, Some("user@example.com".to_string())); + assert_eq!(password, Some("secret".to_string())); + assert!(use_tls); + } + _ => panic!("Expected SMTP provider"), + } + } + + #[test] + fn test_smtp_defaults() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "smtp"); + guard.set("SMTP_HOST", "smtp.example.com"); + guard.remove("SMTP_PORT"); // Should default to 587 + guard.remove("SMTP_USERNAME"); + guard.remove("SMTP_PASSWORD"); + guard.remove("SMTP_USE_TLS"); // Should default to true + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + guard.set("ZOPP_EMAIL_VERIFICATION_REQUIRED", "true"); + + let config = ServerConfig::from_env().unwrap(); + let email = config.email.unwrap(); + + match email.provider { + EmailProviderConfig::Smtp { + port, + username, + password, + use_tls, + .. + } => { + assert_eq!(port, 587); + assert!(username.is_none()); + assert!(password.is_none()); + assert!(use_tls); + } + _ => panic!("Expected SMTP provider"), + } + } + + #[test] + fn test_smtp_missing_host() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "smtp"); + guard.remove("SMTP_HOST"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + + let result = ServerConfig::from_env(); + assert!(matches!(result, Err(ConfigError::SmtpMissingHost))); + } + + #[test] + fn test_invalid_port() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "smtp"); + guard.set("SMTP_HOST", "smtp.example.com"); + guard.set("SMTP_PORT", "not_a_number"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + + let result = ServerConfig::from_env(); + assert!(matches!(result, Err(ConfigError::InvalidPort(_)))); + } + + #[test] + fn test_invalid_provider() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "mailgun"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + + let result = ServerConfig::from_env(); + assert!(matches!(result, Err(ConfigError::InvalidProvider(_)))); + } + + #[test] + fn test_missing_from_address() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "resend"); + guard.set("RESEND_API_KEY", "re_test_key"); + guard.remove("ZOPP_EMAIL_FROM"); + + let result = ServerConfig::from_env(); + assert!(matches!(result, Err(ConfigError::MissingFromAddress))); + } + + #[test] + fn test_verification_disabled() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "resend"); + guard.set("RESEND_API_KEY", "re_test_key"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + guard.set("ZOPP_EMAIL_VERIFICATION_REQUIRED", "false"); + + let config = ServerConfig::from_env().unwrap(); + assert!(!config.is_verification_required()); + let email = config.email.unwrap(); + assert!(!email.verification_required); + } + + #[test] + fn test_provider_case_insensitive() { + let guard = EnvGuard::new(); + guard.set("ZOPP_EMAIL_PROVIDER", "RESEND"); + guard.set("RESEND_API_KEY", "re_test_key"); + guard.set("ZOPP_EMAIL_FROM", "test@example.com"); + + let config = ServerConfig::from_env().unwrap(); + assert!(config.email.is_some()); + match config.email.unwrap().provider { + EmailProviderConfig::Resend { .. } => {} + _ => panic!("Expected Resend provider"), + } + } +} diff --git a/apps/zopp-server/src/email/code.rs b/apps/zopp-server/src/email/code.rs new file mode 100644 index 00000000..e1e673c7 --- /dev/null +++ b/apps/zopp-server/src/email/code.rs @@ -0,0 +1,59 @@ +//! Verification code generation. + +use rand::Rng; + +/// Generate a cryptographically secure 6-digit verification code. +/// +/// Returns a string of exactly 6 digits (000000-999999). +pub fn generate_verification_code() -> String { + let mut rng = rand::rng(); + let code: u32 = rng.random_range(0..1_000_000); + format!("{:06}", code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_code_is_6_digits() { + for _ in 0..100 { + let code = generate_verification_code(); + assert_eq!(code.len(), 6); + } + } + + #[test] + fn test_code_is_numeric() { + for _ in 0..100 { + let code = generate_verification_code(); + assert!(code.chars().all(|c| c.is_ascii_digit())); + } + } + + #[test] + fn test_code_can_start_with_zero() { + // Generate many codes until we find one starting with 0 + // With 1M possibilities and 10% starting with 0, this should happen quickly + let mut found_zero_start = false; + for _ in 0..1000 { + let code = generate_verification_code(); + if code.starts_with('0') { + found_zero_start = true; + break; + } + } + assert!( + found_zero_start, + "Should be able to generate codes starting with 0" + ); + } + + #[test] + fn test_code_randomness() { + use std::collections::HashSet; + // Generate 100 codes - with 1M possibilities, duplicates are extremely unlikely + let codes: HashSet = (0..100).map(|_| generate_verification_code()).collect(); + assert!(codes.len() > 95, "Should generate mostly unique codes"); + } +} diff --git a/apps/zopp-server/src/email/mod.rs b/apps/zopp-server/src/email/mod.rs new file mode 100644 index 00000000..2a5ec1a2 --- /dev/null +++ b/apps/zopp-server/src/email/mod.rs @@ -0,0 +1,99 @@ +//! Email module for verification. +//! +//! This module provides email sending capabilities for the verification flow. + +mod code; +#[cfg(feature = "email-resend")] +mod resend; +#[cfg(feature = "email-smtp")] +mod smtp; +mod templates; + +pub use code::generate_verification_code; +pub use templates::VerificationEmailContent; + +use crate::config::{EmailConfig, EmailProviderConfig}; +use async_trait::async_trait; +use thiserror::Error; + +/// Email sending error +#[derive(Debug, Error)] +pub enum EmailError { + #[error("Failed to send email: {0}")] + SendFailed(String), + + #[error("Invalid configuration: {0}")] + InvalidConfig(String), + + #[error("Provider not available: {0}")] + ProviderNotAvailable(String), +} + +/// Trait for email providers +#[async_trait] +pub trait EmailProvider: Send + Sync { + /// Send a verification email + async fn send_verification( + &self, + to: &str, + code: &str, + from_address: &str, + from_name: Option<&str>, + ) -> Result<(), EmailError>; +} + +/// Create an email provider from configuration +pub fn create_provider(config: &EmailConfig) -> Result, EmailError> { + match &config.provider { + #[cfg(feature = "email-resend")] + EmailProviderConfig::Resend { api_key } => { + Ok(Box::new(resend::ResendProvider::new(api_key.clone()))) + } + #[cfg(not(feature = "email-resend"))] + EmailProviderConfig::Resend { .. } => Err(EmailError::ProviderNotAvailable( + "Resend support not compiled in. Enable the 'email-resend' feature.".to_string(), + )), + #[cfg(feature = "email-smtp")] + EmailProviderConfig::Smtp { + host, + port, + username, + password, + use_tls, + } => { + let provider = smtp::SmtpProvider::new( + host.clone(), + *port, + username.clone(), + password.clone(), + *use_tls, + )?; + Ok(Box::new(provider)) + } + #[cfg(not(feature = "email-smtp"))] + EmailProviderConfig::Smtp { .. } => Err(EmailError::ProviderNotAvailable( + "SMTP support not compiled in. Enable the 'email-smtp' feature.".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verification_code_format() { + let code = generate_verification_code(); + assert_eq!(code.len(), 6); + assert!(code.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn test_verification_code_uniqueness() { + // Generate 100 codes and ensure they're not all the same + let codes: Vec = (0..100).map(|_| generate_verification_code()).collect(); + let unique_codes: std::collections::HashSet<_> = codes.iter().collect(); + // With 1M possible codes, we should get mostly unique values + assert!(unique_codes.len() > 90); + } +} diff --git a/apps/zopp-server/src/email/resend.rs b/apps/zopp-server/src/email/resend.rs new file mode 100644 index 00000000..37916cb0 --- /dev/null +++ b/apps/zopp-server/src/email/resend.rs @@ -0,0 +1,61 @@ +//! Resend email provider implementation. + +use super::{EmailError, EmailProvider, VerificationEmailContent}; +use async_trait::async_trait; +use resend_rs::{types::CreateEmailBaseOptions, Resend}; + +/// Resend email provider. +pub struct ResendProvider { + client: Resend, +} + +impl ResendProvider { + /// Create a new Resend provider with the given API key. + pub fn new(api_key: String) -> Self { + Self { + client: Resend::new(&api_key), + } + } +} + +#[async_trait] +impl EmailProvider for ResendProvider { + async fn send_verification( + &self, + to: &str, + code: &str, + from_address: &str, + from_name: Option<&str>, + ) -> Result<(), EmailError> { + let content = VerificationEmailContent::new(code); + + let from = match from_name { + Some(name) => format!("{} <{}>", name, from_address), + None => from_address.to_string(), + }; + + let email = CreateEmailBaseOptions::new(from, vec![to.to_string()], content.subject) + .with_text(&content.text) + .with_html(&content.html); + + self.client + .emails + .send(email) + .await + .map_err(|e| EmailError::SendFailed(e.to_string()))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_creation() { + let provider = ResendProvider::new("re_test_key".to_string()); + // Just verify it creates without panicking + assert!(std::mem::size_of_val(&provider) > 0); + } +} diff --git a/apps/zopp-server/src/email/smtp.rs b/apps/zopp-server/src/email/smtp.rs new file mode 100644 index 00000000..848b2943 --- /dev/null +++ b/apps/zopp-server/src/email/smtp.rs @@ -0,0 +1,129 @@ +//! SMTP email provider implementation. + +use super::{EmailError, EmailProvider, VerificationEmailContent}; +use async_trait::async_trait; +use lettre::{ + message::{header::ContentType, MultiPart, SinglePart}, + transport::smtp::{ + authentication::Credentials, + client::{Tls, TlsParameters}, + }, + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, +}; + +/// SMTP email provider. +pub struct SmtpProvider { + transport: AsyncSmtpTransport, +} + +impl SmtpProvider { + /// Create a new SMTP provider. + pub fn new( + host: String, + port: u16, + username: Option, + password: Option, + use_tls: bool, + ) -> Result { + let mut builder = if use_tls { + let tls_params = TlsParameters::new(host.clone()).map_err(|e| { + EmailError::InvalidConfig(format!("TLS configuration error: {}", e)) + })?; + + // Port 465 uses implicit TLS (SMTPS), other ports use STARTTLS + if port == 465 { + AsyncSmtpTransport::::relay(&host) + .map_err(|e| EmailError::InvalidConfig(format!("SMTP relay error: {}", e)))? + .port(port) + .tls(Tls::Wrapper(tls_params)) + } else { + AsyncSmtpTransport::::starttls_relay(&host) + .map_err(|e| EmailError::InvalidConfig(format!("SMTP relay error: {}", e)))? + .port(port) + .tls(Tls::Required(tls_params)) + } + } else { + AsyncSmtpTransport::::builder_dangerous(&host).port(port) + }; + + if let (Some(user), Some(pass)) = (username, password) { + builder = builder.credentials(Credentials::new(user, pass)); + } + + let transport = builder.build(); + + Ok(Self { transport }) + } +} + +#[async_trait] +impl EmailProvider for SmtpProvider { + async fn send_verification( + &self, + to: &str, + code: &str, + from_address: &str, + from_name: Option<&str>, + ) -> Result<(), EmailError> { + let content = VerificationEmailContent::new(code); + + let from = match from_name { + Some(name) => format!("{} <{}>", name, from_address), + None => from_address.to_string(), + }; + + let message = + Message::builder() + .from(from.parse().map_err(|e| { + EmailError::InvalidConfig(format!("Invalid from address: {}", e)) + })?) + .to(to + .parse() + .map_err(|e| EmailError::InvalidConfig(format!("Invalid to address: {}", e)))?) + .subject(content.subject) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(content.text), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(content.html), + ), + ) + .map_err(|e| EmailError::SendFailed(format!("Failed to build email: {}", e)))?; + + self.transport + .send(message) + .await + .map_err(|e| EmailError::SendFailed(e.to_string()))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_creation_no_tls() { + let provider = SmtpProvider::new("localhost".to_string(), 25, None, None, false); + assert!(provider.is_ok()); + } + + #[test] + fn test_provider_creation_with_credentials() { + let provider = SmtpProvider::new( + "localhost".to_string(), + 587, + Some("user".to_string()), + Some("pass".to_string()), + false, + ); + assert!(provider.is_ok()); + } +} diff --git a/apps/zopp-server/src/email/templates.rs b/apps/zopp-server/src/email/templates.rs new file mode 100644 index 00000000..55b6e9b7 --- /dev/null +++ b/apps/zopp-server/src/email/templates.rs @@ -0,0 +1,111 @@ +//! Email templates for verification. + +/// Content for verification emails. +pub struct VerificationEmailContent { + pub subject: String, + pub text: String, + pub html: String, +} + +impl VerificationEmailContent { + /// Create verification email content with the given code. + pub fn new(code: &str) -> Self { + Self { + subject: "Your Zopp verification code".to_string(), + text: Self::text_template(code), + html: Self::html_template(code), + } + } + + fn text_template(code: &str) -> String { + format!( + r#"Welcome to Zopp! + +Your verification code is: {} + +This code will expire in 15 minutes. + +If you didn't request this code, please ignore this email. + +-- +Zopp Security Team"#, + code + ) + } + + fn html_template(code: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Welcome to Zopp!

+

Your verification code is:

+
{}
+

This code will expire in 15 minutes.

+ +
+
+ +"#, + code + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_email_content_contains_code() { + let code = "123456"; + let content = VerificationEmailContent::new(code); + + assert!(content.text.contains(code)); + assert!(content.html.contains(code)); + } + + #[test] + fn test_email_subject() { + let content = VerificationEmailContent::new("123456"); + assert_eq!(content.subject, "Your Zopp verification code"); + } + + #[test] + fn test_text_template_format() { + let content = VerificationEmailContent::new("654321"); + + assert!(content.text.contains("Welcome to Zopp!")); + assert!(content.text.contains("654321")); + assert!(content.text.contains("15 minutes")); + assert!(content.text.contains("Zopp Security Team")); + } + + #[test] + fn test_html_template_format() { + let content = VerificationEmailContent::new("999999"); + + assert!(content.html.contains("")); + assert!(content.html.contains("999999")); + assert!(content.html.contains("15 minutes")); + assert!(content.html.contains("Zopp Security Team")); + } +} diff --git a/apps/zopp-server/src/handlers/auth.rs b/apps/zopp-server/src/handlers/auth.rs index 2002b28b..e9c334c0 100644 --- a/apps/zopp-server/src/handlers/auth.rs +++ b/apps/zopp-server/src/handlers/auth.rs @@ -1,15 +1,19 @@ //! Authentication handlers: join, register, login +use chrono::Utc; use prost::Message; use sha2::{Digest, Sha256}; use tonic::{Request, Response, Status}; +use zopp_crypto::argon2_hash; use zopp_proto::{ JoinRequest, JoinResponse, LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, }; use zopp_storage::{ - CreatePrincipalData, CreatePrincipalParams, CreateUserParams, Store, StoreError, + CreateEmailVerificationParams, CreatePrincipalData, CreatePrincipalParams, CreateUserParams, + Store, StoreError, }; +use crate::email::generate_verification_code; use crate::server::{extract_signature, ZoppServer}; pub async fn join( @@ -36,7 +40,135 @@ pub async fn join( ))); } - // Try to create user, but if they already exist, that's okay for workspace invites + // Check if invite was already consumed + if invite.consumed { + return Err(Status::permission_denied( + "This invite has already been used", + )); + } + + // Check if email verification is required + let verification_required = + server.config.is_verification_required() && server.email_provider.is_some(); + + if verification_required { + // Verification required: create user only (no principal yet) + // Principal will be created when email is verified + + // Try to create user (or get existing if this is a retry) + let user_id = match server + .store + .create_user(&CreateUserParams { + email: email.clone(), + principal: None, // No principal yet - created at verification + workspace_ids: vec![], // Workspace membership added at verification + }) + .await + { + Ok((uid, _)) => uid, + Err(StoreError::AlreadyExists) => { + // User exists - check if this is a bootstrap invite + // Bootstrap invites (no workspace_ids) should not allow existing verified users + if invite.workspace_ids.is_empty() { + let existing_user = + server.store.get_user_by_email(&email).await.map_err(|e| { + Status::internal(format!("Failed to get existing user: {}", e)) + })?; + + // If user is verified, reject bootstrap invite + if existing_user.verified { + return Err(Status::already_exists("User already exists")); + } + // If not verified, allow retry (user_id from existing user) + existing_user.id + } else { + // Workspace invite for existing user - this is allowed + server + .store + .get_user_by_email(&email) + .await + .map_err(|e| { + Status::internal(format!("Failed to get existing user: {}", e)) + })? + .id + } + } + Err(e) => return Err(Status::internal(format!("Failed to create user: {}", e))), + }; + + // Check if a valid verification record already exists for this email+invite + // If so, don't regenerate the code (allows retry with same code) + let existing_verification = match server.store.get_email_verification(&email).await { + Ok(v) => Some(v), + Err(zopp_storage::StoreError::NotFound) => None, + Err(e) => { + return Err(Status::internal(format!( + "Failed to check existing verification: {}", + e + ))) + } + }; + let should_generate_new_code = match &existing_verification { + Some(v) if v.invite_token == req.invite_token && v.expires_at > Utc::now() => { + // Valid verification exists for same invite - don't regenerate + false + } + _ => true, + }; + + if should_generate_new_code { + // Generate verification code + let code = generate_verification_code(); + + // Send verification email FIRST before storing record + // This ensures we don't store a record if the email fails to send, + // which would prevent retries from resending the email + if let Some(ref provider) = server.email_provider { + let email_config = server.config.email.as_ref().unwrap(); + provider + .send_verification( + &email, + &code, + &email_config.from_address, + email_config.from_name.as_deref(), + ) + .await + .map_err(|e| { + Status::internal(format!("Failed to send verification email: {}", e)) + })?; + } + + // Email sent successfully - now store verification record (expires in 15 minutes) + // Hash the code using Argon2id with email as salt for zero-knowledge storage + let code_hash = argon2_hash(code.as_bytes(), email.as_bytes()).map_err(|e| { + Status::internal(format!("Failed to hash verification code: {}", e)) + })?; + // This upserts - if there's an existing verification for this email, it's replaced + let expires_at = Utc::now() + chrono::Duration::minutes(15); + server + .store + .create_email_verification(&CreateEmailVerificationParams { + email: email.clone(), + code_hash, + invite_token: req.invite_token.clone(), + expires_at, + }) + .await + .map_err(|e| Status::internal(format!("Failed to create verification: {}", e)))?; + } + + // Return without principal_id - it will be created at verification + return Ok(Response::new(JoinResponse { + user_id: user_id.0.to_string(), + principal_id: None, + workspaces: vec![], // Workspaces returned at verification + verification_required: true, + })); + } + + // No verification required: create user + principal immediately + + // Try to create user with principal let result = server .store .create_user(&CreateUserParams { @@ -105,6 +237,10 @@ pub async fn join( (existing_user.id, new_principal_id) } + Err(StoreError::AlreadyExists) => { + // Bootstrap invite: user already exists + return Err(Status::already_exists("User already exists")); + } Err(e) => return Err(Status::internal(format!("Failed to create user: {}", e))), }; @@ -127,6 +263,20 @@ pub async fn join( } } + // Consume the invite (mark as used) + server + .store + .consume_invite(&req.invite_token) + .await + .map_err(|e| Status::internal(format!("Failed to consume invite: {}", e)))?; + + // Mark user as verified (no email verification needed) + server + .store + .mark_user_verified(&user_id) + .await + .map_err(|e| Status::internal(format!("Failed to mark user verified: {}", e)))?; + let mut workspaces = Vec::new(); for workspace_id in invite.workspace_ids { let workspace = server @@ -143,8 +293,9 @@ pub async fn join( Ok(Response::new(JoinResponse { user_id: user_id.0.to_string(), - principal_id: principal_id.0.to_string(), + principal_id: Some(principal_id.0.to_string()), workspaces, + verification_required: false, })) } diff --git a/apps/zopp-server/src/handlers/mod.rs b/apps/zopp-server/src/handlers/mod.rs index dd4c8f23..d91ed79a 100644 --- a/apps/zopp-server/src/handlers/mod.rs +++ b/apps/zopp-server/src/handlers/mod.rs @@ -2,6 +2,7 @@ //! //! This module contains handler functions organized by domain: //! - auth: join, register, login +//! - verification: email verification for new principals //! - workspaces: create, list, get_keys //! - invites: create, get, list, revoke //! - principals: get, rename, list, service principals, remove, revoke, effective permissions @@ -25,6 +26,7 @@ pub mod principals; pub mod projects; pub mod secrets; pub mod user_permissions; +pub mod verification; pub mod workspaces; use tokio_stream::wrappers::ReceiverStream; @@ -56,6 +58,22 @@ impl ZoppService for ZoppServer { auth::login(self, request).await } + // ───────────────────────────────────── Email Verification ───────────────────────────────────── + + async fn verify_email( + &self, + request: Request, + ) -> Result, Status> { + verification::verify_email(self, request).await + } + + async fn resend_verification( + &self, + request: Request, + ) -> Result, Status> { + verification::resend_verification(self, request).await + } + // ───────────────────────────────────── Workspaces ───────────────────────────────────── async fn create_workspace( diff --git a/apps/zopp-server/src/handlers/verification.rs b/apps/zopp-server/src/handlers/verification.rs new file mode 100644 index 00000000..8196a15a --- /dev/null +++ b/apps/zopp-server/src/handlers/verification.rs @@ -0,0 +1,362 @@ +//! Email verification handlers. + +use chrono::Utc; +use tonic::{Request, Response, Status}; +use zopp_crypto::argon2_hash; +use zopp_storage::{CreateEmailVerificationParams, Store}; + +use crate::email::generate_verification_code; +use crate::server::ZoppServer; +use zopp_proto::{ + ResendVerificationRequest, ResendVerificationResponse, VerifyEmailRequest, VerifyEmailResponse, +}; + +/// Maximum verification attempts per code +const MAX_ATTEMPTS: i32 = 5; + +/// Handle email verification request. +/// +/// This RPC does not require authentication since the user hasn't completed +/// the join flow yet. On success, creates the principal and returns the principal_id. +pub async fn verify_email( + server: &ZoppServer, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + // Validate inputs + if req.email.is_empty() { + return Err(Status::invalid_argument("email is required")); + } + if req.code.is_empty() { + return Err(Status::invalid_argument("code is required")); + } + if req.principal_name.is_empty() { + return Err(Status::invalid_argument("principal_name is required")); + } + if req.public_key.is_empty() { + return Err(Status::invalid_argument("public_key is required")); + } + + // Normalize email + let email = req.email.to_lowercase(); + + // Get the verification record for this email + let verification = match server.store.get_email_verification(&email).await { + Ok(v) => v, + Err(zopp_storage::StoreError::NotFound) => { + return Ok(Response::new(VerifyEmailResponse { + success: false, + message: "No pending verification found. Please request a new code.".to_string(), + attempts_remaining: 0, + user_id: String::new(), + principal_id: String::new(), + workspaces: vec![], + })); + } + Err(e) => { + return Err(Status::internal(format!( + "Failed to get verification: {}", + e + ))); + } + }; + + // Check if expired + if verification.expires_at < chrono::Utc::now() { + // Clean up expired verification + let _ = server + .store + .delete_email_verification(&verification.id) + .await; + return Ok(Response::new(VerifyEmailResponse { + success: false, + message: "Verification code has expired. Please request a new code.".to_string(), + attempts_remaining: 0, + user_id: String::new(), + principal_id: String::new(), + workspaces: vec![], + })); + } + + // Check attempt limit + if verification.attempts >= MAX_ATTEMPTS { + // Delete the verification after max attempts + let _ = server + .store + .delete_email_verification(&verification.id) + .await; + return Ok(Response::new(VerifyEmailResponse { + success: false, + message: "Too many failed attempts. Please request a new code.".to_string(), + attempts_remaining: 0, + user_id: String::new(), + principal_id: String::new(), + workspaces: vec![], + })); + } + + // Hash the submitted code and compare with stored hash (email is already lowercased) + let submitted_hash = argon2_hash(req.code.as_bytes(), email.as_bytes()) + .map_err(|e| Status::internal(format!("Failed to hash verification code: {}", e)))?; + let code_matches: bool = + subtle::ConstantTimeEq::ct_eq(submitted_hash.as_bytes(), verification.code_hash.as_bytes()) + .into(); + + if !code_matches { + // Increment attempts + let attempts = server + .store + .increment_email_verification_attempts(&verification.id) + .await + .map_err(|e| Status::internal(format!("Failed to increment attempts: {}", e)))?; + + // Check if we've now reached MAX_ATTEMPTS after incrementing + if attempts >= MAX_ATTEMPTS { + // Delete the verification record - user must request a new code + let _ = server + .store + .delete_email_verification(&verification.id) + .await; + + return Ok(Response::new(VerifyEmailResponse { + success: false, + message: "Too many failed attempts. Please request a new verification code." + .to_string(), + attempts_remaining: 0, + user_id: String::new(), + principal_id: String::new(), + workspaces: vec![], + })); + } + + let remaining = (MAX_ATTEMPTS - attempts).max(0); + return Ok(Response::new(VerifyEmailResponse { + success: false, + message: format!( + "Invalid verification code. {} attempts remaining.", + remaining + ), + attempts_remaining: remaining, + user_id: String::new(), + principal_id: String::new(), + workspaces: vec![], + })); + } + + // Code is correct! Now create the principal and complete the join flow. + + // Get the invite from the stored token + let invite = server + .store + .get_invite_by_token(&verification.invite_token) + .await + .map_err(|e| Status::internal(format!("Failed to get invite: {}", e)))?; + + // Check if invite was consumed (someone else used it while we were verifying) + if invite.consumed { + // Clean up + let _ = server + .store + .delete_email_verification(&verification.id) + .await; + return Ok(Response::new(VerifyEmailResponse { + success: false, + message: "This invite has already been used.".to_string(), + attempts_remaining: 0, + user_id: String::new(), + principal_id: String::new(), + workspaces: vec![], + })); + } + + // Get the user (created during join) + let user = server + .store + .get_user_by_email(&email) + .await + .map_err(|e| Status::internal(format!("Failed to get user: {}", e)))?; + + // Create the principal + let principal_id = server + .store + .create_principal(&zopp_storage::CreatePrincipalParams { + user_id: Some(user.id.clone()), + name: req.principal_name.clone(), + public_key: req.public_key.clone(), + x25519_public_key: if req.x25519_public_key.is_empty() { + None + } else { + Some(req.x25519_public_key.clone()) + }, + }) + .await + .map_err(|e| Status::internal(format!("Failed to create principal: {}", e)))?; + + // Add user to workspace memberships + for workspace_id in &invite.workspace_ids { + if let Err(e) = server + .store + .add_user_to_workspace(workspace_id, &user.id) + .await + { + // Ignore AlreadyExists errors - user may already be a member + if !matches!(e, zopp_storage::StoreError::AlreadyExists) { + return Err(Status::internal(format!( + "Failed to add user to workspace: {}", + e + ))); + } + } + } + + // For workspace invites, store the wrapped KEK for this principal + if !invite.workspace_ids.is_empty() && !req.kek_wrapped.is_empty() { + for workspace_id in &invite.workspace_ids { + server + .store + .add_workspace_principal(&zopp_storage::AddWorkspacePrincipalParams { + workspace_id: workspace_id.clone(), + principal_id: principal_id.clone(), + ephemeral_pub: req.ephemeral_pub.clone(), + kek_wrapped: req.kek_wrapped.clone(), + kek_nonce: req.kek_nonce.clone(), + }) + .await + .map_err(|e| { + Status::internal(format!("Failed to add principal to workspace: {}", e)) + })?; + } + } + + // Consume the invite (mark as used) + server + .store + .consume_invite(&verification.invite_token) + .await + .map_err(|e| Status::internal(format!("Failed to consume invite: {}", e)))?; + + // Mark user as verified + server + .store + .mark_user_verified(&user.id) + .await + .map_err(|e| Status::internal(format!("Failed to mark user verified: {}", e)))?; + + // Delete the verification record + let _ = server + .store + .delete_email_verification(&verification.id) + .await; + + // Build workspaces response + let mut workspaces = Vec::new(); + for workspace_id in &invite.workspace_ids { + let workspace = server + .store + .get_workspace(workspace_id) + .await + .map_err(|e| Status::internal(format!("Failed to get workspace: {}", e)))?; + workspaces.push(zopp_proto::Workspace { + id: workspace.id.0.to_string(), + name: workspace.name, + project_count: 0, // Not needed for verification response + }); + } + + Ok(Response::new(VerifyEmailResponse { + success: true, + message: "Email verified successfully.".to_string(), + attempts_remaining: 0, + user_id: user.id.0.to_string(), + principal_id: principal_id.0.to_string(), + workspaces, + })) +} + +/// Handle resend verification request. +/// +/// This RPC does not require authentication since the user hasn't completed +/// the join flow yet. Generates a new code for an existing verification. +pub async fn resend_verification( + server: &ZoppServer, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + // Validate inputs + if req.email.is_empty() { + return Err(Status::invalid_argument("email is required")); + } + + // Normalize email + let email = req.email.to_lowercase(); + + // Check if email provider is configured + let Some(ref provider) = server.email_provider else { + return Ok(Response::new(ResendVerificationResponse { + success: false, + message: "Email provider not configured. Contact your administrator.".to_string(), + })); + }; + + // Get existing verification to get the invite_token + let existing = match server.store.get_email_verification(&email).await { + Ok(v) => v, + Err(zopp_storage::StoreError::NotFound) => { + return Ok(Response::new(ResendVerificationResponse { + success: false, + message: "No pending verification found. Please start the join process again." + .to_string(), + })); + } + Err(e) => { + return Err(Status::internal(format!( + "Failed to get verification: {}", + e + ))); + } + }; + + // Generate new verification code + let code = generate_verification_code(); + + // Send verification email FIRST before updating DB + // This ensures we don't lose the old code if sending fails + let email_config = server.config.email.as_ref().unwrap(); + if let Err(e) = provider + .send_verification( + &email, + &code, + &email_config.from_address, + email_config.from_name.as_deref(), + ) + .await + { + eprintln!("Failed to send verification email to {}: {}", email, e); + return Ok(Response::new(ResendVerificationResponse { + success: false, + message: "Failed to send verification email. Please try again later.".to_string(), + })); + } + + // Email sent successfully - now update verification record with hashed code + let code_hash = argon2_hash(code.as_bytes(), email.as_bytes()) + .map_err(|e| Status::internal(format!("Failed to hash verification code: {}", e)))?; + let expires_at = Utc::now() + chrono::Duration::minutes(15); + server + .store + .create_email_verification(&CreateEmailVerificationParams { + email: email.clone(), + code_hash, + invite_token: existing.invite_token, // Preserve the original invite token + expires_at, + }) + .await + .map_err(|e| Status::internal(format!("Failed to update verification: {}", e)))?; + + Ok(Response::new(ResendVerificationResponse { + success: true, + message: "Verification code sent. Please check your email.".to_string(), + })) +} diff --git a/apps/zopp-server/src/main.rs b/apps/zopp-server/src/main.rs index 903a5203..904cf7f0 100644 --- a/apps/zopp-server/src/main.rs +++ b/apps/zopp-server/src/main.rs @@ -1,4 +1,6 @@ mod backend; +mod config; +mod email; mod handlers; mod server; @@ -302,9 +304,49 @@ async fn cmd_serve_with_ready( } }; + // Load server configuration from environment + let server_config = config::ServerConfig::from_env() + .map_err(|e| format!("Failed to load server configuration: {}", e))?; + + // Create email provider if configured + let email_provider: Option> = if let Some(ref email_config) = + server_config.email + { + match email::create_provider(email_config) { + Ok(provider) => { + println!("Email verification enabled"); + Some(Arc::from(provider)) + } + Err(e) => { + if server_config.is_verification_required() { + // If verification is required but provider init failed, abort startup + return Err(format!( + "Email verification is required but provider initialization failed: {}", + e + ) + .into()); + } + eprintln!("Warning: Failed to create email provider: {}. Email verification will be disabled.", e); + None + } + } + } else { + if server_config.is_verification_required() { + // If verification is required but no provider configured, abort startup + return Err( + "Email verification is required but no email provider configured. Set ZOPP_EMAIL_PROVIDER environment variable.".into() + ); + } + None + }; + let server = match backend { - StoreBackend::Sqlite(ref s) => ZoppServer::new_sqlite(s.clone(), events), - StoreBackend::Postgres(ref s) => ZoppServer::new_postgres(s.clone(), events), + StoreBackend::Sqlite(ref s) => { + ZoppServer::new_sqlite(s.clone(), events, server_config, email_provider) + } + StoreBackend::Postgres(ref s) => { + ZoppServer::new_postgres(s.clone(), events, server_config, email_provider) + } }; // Create gRPC health service (implements gRPC health checking protocol) diff --git a/apps/zopp-server/src/server.rs b/apps/zopp-server/src/server.rs index e67187cc..61217b3c 100644 --- a/apps/zopp-server/src/server.rs +++ b/apps/zopp-server/src/server.rs @@ -1,4 +1,6 @@ use crate::backend::StoreBackend; +use crate::config::ServerConfig; +use crate::email::EmailProvider; use chrono::Utc; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use prost::Message; @@ -15,20 +17,36 @@ use zopp_store_sqlite::SqliteStore; pub struct ZoppServer { pub store: StoreBackend, pub events: Arc, + pub config: Arc, + pub email_provider: Option>, } impl ZoppServer { - pub fn new_sqlite(store: Arc, events: Arc) -> Self { + pub fn new_sqlite( + store: Arc, + events: Arc, + config: ServerConfig, + email_provider: Option>, + ) -> Self { Self { store: StoreBackend::Sqlite(store), events, + config: Arc::new(config), + email_provider, } } - pub fn new_postgres(store: Arc, events: Arc) -> Self { + pub fn new_postgres( + store: Arc, + events: Arc, + config: ServerConfig, + email_provider: Option>, + ) -> Self { Self { store: StoreBackend::Postgres(store), events, + config: Arc::new(config), + email_provider, } } @@ -1002,6 +1020,23 @@ impl ZoppServer { .verify(&message, &sig) .map_err(|_| Status::unauthenticated("Invalid signature"))?; + // Check if user is verified (when verification is required) + // Service principals (no user_id) are always allowed + if self.config.is_verification_required() { + if let Some(user_id) = &principal.user_id { + let user = self + .store + .get_user_by_id(user_id) + .await + .map_err(|e| Status::internal(format!("Failed to get user: {}", e)))?; + if !user.verified { + return Err(Status::permission_denied( + "Email verification required. Please verify your email to continue.", + )); + } + } + } + Ok(principal) } diff --git a/apps/zopp-server/src/tests.rs b/apps/zopp-server/src/tests.rs index 7237cd30..a40e95ca 100644 --- a/apps/zopp-server/src/tests.rs +++ b/apps/zopp-server/src/tests.rs @@ -1,6 +1,7 @@ //! Unit tests for server logic using real SQLite in-memory database. use crate::backend::StoreBackend; +use crate::config::ServerConfig; use crate::server::{extract_signature, ZoppServer}; use chrono::{Duration, Utc}; use ed25519_dalek::{Signer, SigningKey}; @@ -17,7 +18,34 @@ use zopp_store_sqlite::SqliteStore; async fn create_test_server() -> ZoppServer { let store = Arc::new(SqliteStore::open_in_memory().await.unwrap()); let events = Arc::new(MemoryEventBus::new()); - ZoppServer::new_sqlite(store, events) + // Use default config with no email verification for tests + let config = ServerConfig::default(); + ZoppServer::new_sqlite(store, events, config, None) +} + +/// Test helper: Create a ZoppServer with email verification required +async fn create_test_server_with_verification() -> ZoppServer { + use crate::config::{EmailConfig, EmailProviderConfig}; + + let store = Arc::new(SqliteStore::open_in_memory().await.unwrap()); + let events = Arc::new(MemoryEventBus::new()); + // Config with verification required + let config = ServerConfig { + email: Some(EmailConfig { + verification_required: true, + provider: EmailProviderConfig::Smtp { + host: "localhost".to_string(), + port: 25, + username: None, + password: None, + use_tls: false, + }, + from_address: "test@example.com".to_string(), + from_name: None, + }), + }; + // No actual email provider - we're testing enforcement, not email sending + ZoppServer::new_sqlite(store, events, config, None) } /// Test helper: Generate a random Ed25519 keypair and return (public_key, private_key) @@ -37,7 +65,8 @@ fn generate_x25519_keypair() -> (Vec, [u8; 32]) { (public.as_bytes().to_vec(), private_key) } -/// Test helper: Create a user with principal for testing +/// Test helper: Create a user with principal for testing. +/// The user is automatically verified (non-verification flow behavior). async fn create_test_user( server: &ZoppServer, email: &str, @@ -64,6 +93,42 @@ async fn create_test_user( (user_id, principal_id.unwrap(), signing_key) } +/// Test helper: Create an unverified user with principal. +/// This simulates the state after join but before email verification completes. +async fn create_unverified_test_user( + server: &ZoppServer, + email: &str, + principal_name: &str, +) -> (UserId, PrincipalId, SigningKey) { + let (public_key, signing_key) = generate_keypair(); + let (x25519_public, _) = generate_x25519_keypair(); + + // Create user WITHOUT principal (verified=false) + let (user_id, _) = server + .store + .create_user(&CreateUserParams { + email: email.to_string(), + principal: None, // No principal = unverified + workspace_ids: vec![], + }) + .await + .unwrap(); + + // Create principal separately + let principal_id = server + .store + .create_principal(&CreatePrincipalParams { + user_id: Some(user_id.clone()), + name: principal_name.to_string(), + public_key, + x25519_public_key: Some(x25519_public), + }) + .await + .unwrap(); + + (user_id, principal_id, signing_key) +} + /// Test helper: Create a workspace owned by a user async fn create_test_workspace( server: &ZoppServer, @@ -1118,14 +1183,15 @@ async fn test_server_invite_joins_user_without_creating_workspace() { let response = server.join(request).await.unwrap().into_inner(); assert!(!response.user_id.is_empty()); - assert!(!response.principal_id.is_empty()); + assert!(!response.principal_id.as_ref().is_none_or(|s| s.is_empty())); assert_eq!( response.workspaces.len(), 0, "No workspaces should be created automatically" ); - let principal_id = PrincipalId(uuid::Uuid::parse_str(&response.principal_id).unwrap()); + let principal_id = + PrincipalId(uuid::Uuid::parse_str(response.principal_id.as_ref().unwrap()).unwrap()); let workspaces = server.store.list_workspaces(&principal_id).await.unwrap(); assert_eq!( @@ -1175,6 +1241,170 @@ mod handler_tests { use super::*; use zopp_proto::zopp_service_server::ZoppService; + // ---- Email verification enforcement tests ---- + + #[tokio::test] + async fn unverified_principal_blocked_when_verification_required() { + // Server with verification required + let server = create_test_server_with_verification().await; + + // Create unverified user with principal + let (_user_id, principal_id, signing_key) = + create_unverified_test_user(&server, "test@example.com", "laptop").await; + + // Try to create workspace - should be blocked because principal is not verified + let request = create_signed_request( + &principal_id, + &signing_key, + "/zopp.ZoppService/CreateWorkspace", + zopp_proto::CreateWorkspaceRequest { + id: uuid::Uuid::now_v7().to_string(), + name: "my-workspace".to_string(), + ephemeral_pub: vec![0u8; 32], + kek_wrapped: vec![0u8; 48], + kek_nonce: vec![0u8; 24], + }, + ); + + let result = server.create_workspace(request).await; + assert!(result.is_err(), "Unverified principal should be blocked"); + let status = result.unwrap_err(); + assert_eq!(status.code(), tonic::Code::PermissionDenied); + assert!( + status.message().contains("verification"), + "Error should mention verification: {}", + status.message() + ); + } + + #[tokio::test] + async fn verified_principal_allowed_when_verification_required() { + // Server with verification required + let server = create_test_server_with_verification().await; + + // Create user with principal + let (user_id, principal_id, signing_key) = + create_test_user(&server, "test@example.com", "laptop").await; + + // Mark user as verified + server.store.mark_user_verified(&user_id).await.unwrap(); + + // Try to create workspace - should succeed because principal is verified + let request = create_signed_request( + &principal_id, + &signing_key, + "/zopp.ZoppService/CreateWorkspace", + zopp_proto::CreateWorkspaceRequest { + id: uuid::Uuid::now_v7().to_string(), + name: "my-workspace".to_string(), + ephemeral_pub: vec![0u8; 32], + kek_wrapped: vec![0u8; 48], + kek_nonce: vec![0u8; 24], + }, + ); + + let result = server.create_workspace(request).await; + assert!(result.is_ok(), "Verified principal should be allowed"); + } + + #[tokio::test] + async fn unverified_principal_allowed_when_verification_not_required() { + // Server without verification required (default) + let server = create_test_server().await; + + // Create user with unverified principal + let (_user_id, principal_id, signing_key) = + create_test_user(&server, "test@example.com", "laptop").await; + + // Try to create workspace - should succeed because verification is not required + let request = create_signed_request( + &principal_id, + &signing_key, + "/zopp.ZoppService/CreateWorkspace", + zopp_proto::CreateWorkspaceRequest { + id: uuid::Uuid::now_v7().to_string(), + name: "my-workspace".to_string(), + ephemeral_pub: vec![0u8; 32], + kek_wrapped: vec![0u8; 48], + kek_nonce: vec![0u8; 24], + }, + ); + + let result = server.create_workspace(request).await; + assert!( + result.is_ok(), + "Unverified principal should be allowed when verification not required" + ); + } + + #[tokio::test] + async fn unverified_principal_blocked_list_workspaces() { + // Server with verification required + let server = create_test_server_with_verification().await; + + // Create unverified user with principal + let (_user_id, principal_id, signing_key) = + create_unverified_test_user(&server, "test@example.com", "laptop").await; + + // Try to list workspaces - should be blocked + let request = create_signed_request( + &principal_id, + &signing_key, + "/zopp.ZoppService/ListWorkspaces", + zopp_proto::Empty {}, + ); + + let result = server.list_workspaces(request).await; + assert!(result.is_err(), "Unverified principal should be blocked"); + assert_eq!(result.unwrap_err().code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn unverified_principal_blocked_get_secret() { + // Server with verification required + let server = create_test_server_with_verification().await; + + // Create verified user to set up workspace/project/env + let (user_id, _owner_principal_id, _) = + create_test_user(&server, "owner@example.com", "owner-laptop").await; + server.store.mark_user_verified(&user_id).await.unwrap(); + + let ws_id = create_test_workspace(&server, &user_id, "test-ws").await; + let proj_id = create_test_project(&server, &ws_id, "test-proj").await; + let _env_id = create_test_environment(&server, &proj_id, "dev").await; + + // Create unverified principal + let (_, unverified_principal_id, unverified_signing_key) = + create_unverified_test_user(&server, "unverified@example.com", "unverified-laptop") + .await; + + // Add unverified user to workspace + let unverified_user = server + .store + .get_user_by_email("unverified@example.com") + .await + .unwrap(); + add_user_to_workspace(&server, &ws_id, &unverified_user.id, Role::Read).await; + add_principal_to_workspace(&server, &ws_id, &unverified_principal_id).await; + + // Try to get secret - should be blocked + let request = create_signed_request( + &unverified_principal_id, + &unverified_signing_key, + "/zopp.ZoppService/GetSecret", + zopp_proto::GetSecretRequest { + workspace_name: "test-ws".to_string(), + project_name: "test-proj".to_string(), + environment_name: "dev".to_string(), + key: "DATABASE_URL".to_string(), + }, + ); + + let result = server.get_secret(request).await; + assert!(result.is_err(), "Unverified principal should be blocked"); + assert_eq!(result.unwrap_err().code(), tonic::Code::PermissionDenied); + } + // ---- Workspace handlers ---- #[tokio::test] @@ -3999,7 +4229,7 @@ mod handler_tests { let response = server.join(request).await.unwrap().into_inner(); assert!(!response.user_id.is_empty()); - assert!(!response.principal_id.is_empty()); + assert!(!response.principal_id.as_ref().is_none_or(|s| s.is_empty())); } #[tokio::test] @@ -4447,7 +4677,7 @@ mod handler_tests { let response = server.join(request).await.unwrap().into_inner(); assert!(!response.user_id.is_empty()); - assert!(!response.principal_id.is_empty()); + assert!(!response.principal_id.as_ref().is_none_or(|s| s.is_empty())); assert_eq!(response.workspaces.len(), 1); assert_eq!(response.workspaces[0].name, "shared-ws"); } @@ -5474,7 +5704,7 @@ mod handler_tests { let response = server.join(join_request).await.unwrap().into_inner(); assert!(!response.user_id.is_empty()); - assert!(!response.principal_id.is_empty()); + assert!(!response.principal_id.as_ref().is_none_or(|s| s.is_empty())); assert_eq!(response.workspaces.len(), 1); assert_eq!(response.workspaces[0].name, "shared-ws"); } @@ -14245,7 +14475,7 @@ mod handler_tests { assert!(result.is_ok()); let response = result.unwrap().into_inner(); assert_eq!(response.user_id, existing_user_id.0.to_string()); - assert!(!response.principal_id.is_empty()); + assert!(!response.principal_id.as_ref().is_none_or(|s| s.is_empty())); assert_eq!(response.workspaces.len(), 1); } diff --git a/apps/zopp-web/package-lock.json b/apps/zopp-web/package-lock.json index 8e9153cb..15fbebaf 100644 --- a/apps/zopp-web/package-lock.json +++ b/apps/zopp-web/package-lock.json @@ -9,9 +9,13 @@ "version": "0.1.1", "devDependencies": { "@playwright/test": "^1.40.0", + "@types/mailparser": "^3.4.6", + "@types/smtp-server": "^3.5.12", "autoprefixer": "^10.4.0", "daisyui": "^4.0.0", + "mailparser": "^3.9.1", "postcss": "^8.4.0", + "smtp-server": "^3.18.0", "tailwindcss": "^3.4.0" } }, @@ -28,97 +32,1626 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.975.0.tgz", + "integrity": "sha512-4R+hR6N2LbvTIf6Y2e9b9PQlVkAD5WmSRMAGslul5L/jCE0LzOYC+4RQ7u5EOv0mERozcYleLPK2Zc0jTn4gTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/credential-provider-node": "^3.972.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/signature-v4-multi-region": "3.972.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.974.0.tgz", + "integrity": "sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.10", + "@smithy/middleware-retry": "^4.4.26", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.25", + "@smithy/util-defaults-mode-node": "^4.2.28", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.1.tgz", + "integrity": "sha512-Ocubx42QsMyVs9ANSmFpRm0S+hubWljpPLjOi9UFrtcnVJjrVJTzQ51sN0e5g4e8i8QZ7uY73zosLmgYL7kZTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/xml-builder": "^3.972.1", + "@smithy/core": "^3.21.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.1.tgz", + "integrity": "sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.2.tgz", + "integrity": "sha512-mXgdaUfe5oM+tWKyeZ7Vh/iQ94FrkMky1uuzwTOmFADiRcSk5uHy/e3boEFedXiT/PRGzgBmqvJVK4F6lUISCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/types": "^3.973.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.1.tgz", + "integrity": "sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/credential-provider-env": "^3.972.1", + "@aws-sdk/credential-provider-http": "^3.972.1", + "@aws-sdk/credential-provider-login": "^3.972.1", + "@aws-sdk/credential-provider-process": "^3.972.1", + "@aws-sdk/credential-provider-sso": "^3.972.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.1", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.1.tgz", + "integrity": "sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.1.tgz", + "integrity": "sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.1", + "@aws-sdk/credential-provider-http": "^3.972.1", + "@aws-sdk/credential-provider-ini": "^3.972.1", + "@aws-sdk/credential-provider-process": "^3.972.1", + "@aws-sdk/credential-provider-sso": "^3.972.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.1.tgz", + "integrity": "sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.1.tgz", + "integrity": "sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.974.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/token-providers": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.1.tgz", + "integrity": "sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.1.tgz", + "integrity": "sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.1.tgz", + "integrity": "sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.1.tgz", + "integrity": "sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.0.tgz", + "integrity": "sha512-0bcKFXWx+NZ7tIlOo7KjQ+O2rydiHdIQahrq+fN6k9Osky29v17guy68urUKfhTobR6iY6KvxkroFWaFtTgS5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.972.0", + "@aws-sdk/types": "3.972.0", + "@aws-sdk/util-arn-parser": "3.972.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/@aws-sdk/core": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.972.0.tgz", + "integrity": "sha512-nEeUW2M9F+xdIaD98F5MBcQ4ITtykj3yKbgFZ6J0JtL3bq+Z90szQ6Yy8H/BLPYXTs3V4n9ifnBo8cprRDiE6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@aws-sdk/xml-builder": "3.972.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.0.tgz", + "integrity": "sha512-POaGMcXnozzqBUyJM3HLUZ9GR6OKJWPGJEmhtTnxZXt8B6JcJ/6K3xRJ5H/j8oovVLz8Wg6vFxAHv8lvuASxMg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.2.tgz", + "integrity": "sha512-d+Exq074wy0X6wvShg/kmZVtkah+28vMuqCtuY3cydg8LUZOJBtbAolCpEJizSyb8mJJZF9BjWaTANXL4OYnkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@smithy/core": "^3.21.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.974.0.tgz", + "integrity": "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.10", + "@smithy/middleware-retry": "^4.4.26", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.25", + "@smithy/util-defaults-mode-node": "^4.2.28", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.1.tgz", + "integrity": "sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.972.0.tgz", + "integrity": "sha512-2udiRijmjpN81Pvajje4TsjbXDZNP6K9bYUanBYH8hXa/tZG5qfGCySD+TyX0sgDxCQmEDMg3LaQdfjNHBDEgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.972.0", + "@aws-sdk/types": "3.972.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.974.0.tgz", + "integrity": "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.0.tgz", + "integrity": "sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.0.tgz", + "integrity": "sha512-RM5Mmo/KJ593iMSrALlHEOcc9YOIyOsDmS5x2NLOMdEmzv1o00fcpAkCQ02IGu1eFneBFT7uX0Mpag0HI+Cz2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz", + "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.3.tgz", + "integrity": "sha512-FNUqAjlKAGA7GM05kywE99q8wiPHPZqrzhq3wXRga6PRD6A0kzT85Pb0AzYBVTBRpSrKyyr6M92Y6bnSBVp2BA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.1.tgz", + "integrity": "sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.1.tgz", + "integrity": "sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.1.tgz", + "integrity": "sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "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==", "dev": true, - "license": "MIT", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.21.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.1.tgz", + "integrity": "sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "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==", + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.11.tgz", + "integrity": "sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.21.1", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.27.tgz", + "integrity": "sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz", + "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.12", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.12.tgz", + "integrity": "sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.21.1", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.26", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.26.tgz", + "integrity": "sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.29", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.29.tgz", + "integrity": "sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.10", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz", + "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "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==", + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@types/node": "*", + "iconv-lite": "^0.6.3" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 8" + "node": ">=0.10.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "undici-types": "~7.16.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@types/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" } }, - "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "node_modules/@types/smtp-server": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@types/smtp-server/-/smtp-server-3.5.12.tgz", + "integrity": "sha512-IBemrqI6nzvbgwE41Lnd4v4Yf1Kc7F1UHjk1GFBLNhLcI/Zop1ggHQ8g7Y8QYc6jGVgzWQcsa0MBNcGnDY9UGw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright": "1.57.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" + "@types/node": "*", + "@types/nodemailer": "*" + } + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "dev": true, + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" } }, "node_modules/any-promise": { @@ -186,6 +1719,16 @@ "postcss": "^8.1.0" } }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", @@ -209,6 +1752,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "dev": true, + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -389,6 +1939,16 @@ "url": "https://opencollective.com/daisyui" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -403,6 +1963,65 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -410,6 +2029,29 @@ "dev": true, "license": "ISC" }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -450,6 +2092,25 @@ "node": ">= 6" } }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastparse": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", @@ -545,6 +2206,77 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ipv6-normalize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", + "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -617,6 +2349,56 @@ "jiti": "bin/jiti.js" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "dev": true, + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "dev": true, + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -637,6 +2419,35 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz", + "integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.0", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "7.0.11", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -699,6 +2510,16 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "dev": true, + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -729,6 +2550,20 @@ "node": ">= 6" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -736,6 +2571,16 @@ "dev": true, "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -971,6 +2816,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1071,6 +2926,42 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/smtp-server": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.18.0.tgz", + "integrity": "sha512-xINTnh0H8JDAKOAGSnFX8mgXB/L4Oz8dG4P0EgKAzJEszngxEEx4vOys+yNpsUc6yIyTKS8m2BcIffq4Htma/w==", + "dev": true, + "license": "MIT-0", + "dependencies": { + "base32.js": "0.1.0", + "ipv6-normalize": "1.0.1", + "nodemailer": "7.0.11", + "punycode.js": "2.3.1" + }, + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1081,6 +2972,19 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -1226,6 +3130,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "dev": true, + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1246,6 +3160,27 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/apps/zopp-web/package.json b/apps/zopp-web/package.json index 23a8af87..c3de5218 100644 --- a/apps/zopp-web/package.json +++ b/apps/zopp-web/package.json @@ -10,10 +10,14 @@ "test:e2e": "playwright test" }, "devDependencies": { - "tailwindcss": "^3.4.0", - "daisyui": "^4.0.0", + "@playwright/test": "^1.40.0", + "@types/mailparser": "^3.4.6", + "@types/smtp-server": "^3.5.12", "autoprefixer": "^10.4.0", + "daisyui": "^4.0.0", + "mailparser": "^3.9.1", "postcss": "^8.4.0", - "@playwright/test": "^1.40.0" + "smtp-server": "^3.18.0", + "tailwindcss": "^3.4.0" } } diff --git a/apps/zopp-web/src/pages/register.rs b/apps/zopp-web/src/pages/register.rs index 5d82e76a..59f4d62b 100644 --- a/apps/zopp-web/src/pages/register.rs +++ b/apps/zopp-web/src/pages/register.rs @@ -6,18 +6,40 @@ use leptos_router::hooks::use_navigate; use crate::services::storage::{IndexedDbStorage, KeyStorage, StoredPrincipal}; use crate::state::auth::use_auth; +/// State for pending verification +#[derive(Clone)] +#[allow(dead_code)] // Fields used conditionally in wasm32 target +struct PendingVerification { + result: JoinResult, + email: String, + device_name: String, +} + #[component] pub fn RegisterPage() -> impl IntoView { let auth = use_auth(); let navigate = use_navigate(); - let navigate_for_effect = navigate.clone(); + // Join form state let (invite_token, set_invite_token) = signal(String::new()); let (email, set_email) = signal(String::new()); let (device_name, set_device_name) = signal(String::new()); let (error, set_error) = signal::>(None); let (loading, set_loading) = signal(false); + // Verification state + let (pending_verification, set_pending_verification) = + signal::>(None); + let (verification_code, set_verification_code) = signal(String::new()); + let (verification_error, set_verification_error) = signal::>(None); + let (verification_success, set_verification_success) = signal::>(None); + let (verifying, set_verifying) = signal(false); + let (resending, set_resending) = signal(false); + + // Store auth for use in completion + let auth_for_complete = auth; + let _ = navigate; // Navigation handled via window.location + let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let token = invite_token.get(); @@ -29,7 +51,6 @@ pub fn RegisterPage() -> impl IntoView { return; } - // Validate invite token format if !token.starts_with("inv_") { set_error.set(Some( "Invalid invite token format (must start with 'inv_')".to_string(), @@ -40,69 +61,38 @@ pub fn RegisterPage() -> impl IntoView { set_loading.set(true); set_error.set(None); - let auth_clone = auth; - let navigate_clone = navigate.clone(); + let auth_clone = auth_for_complete; spawn_local(async move { match join_workspace(&token, &mail, &device).await { Ok(result) => { - // Store credentials in IndexedDB with encryption - #[cfg(target_arch = "wasm32")] - { - let storage = IndexedDbStorage::new(); - let stored = StoredPrincipal { - id: result.principal_id.clone(), - name: device.clone(), - email: Some(mail.clone()), - user_id: Some(result.user_id.clone()), - ed25519_private_key: result.ed25519_private_key.clone(), - ed25519_public_key: result.ed25519_public_key.clone(), - x25519_private_key: Some(result.x25519_private_key.clone()), - x25519_public_key: Some(result.x25519_public_key.clone()), - ed25519_nonce: None, - x25519_nonce: None, - encrypted: false, // Will be encrypted by store_principal - }; - if let Err(e) = storage.store_principal(stored).await { - set_error.set(Some(format!("Failed to store principal: {}", e))); - set_loading.set(false); - return; - } - // Set current principal - if let Err(e) = storage - .set_current_principal_id(Some(&result.principal_id)) - .await + if result.verification_required { + // Need email verification - store result and show verification UI + set_pending_verification.set(Some(PendingVerification { + result, + email: mail, + device_name: device, + })); + set_loading.set(false); + } else { + // No verification needed - complete registration + if complete_registration_impl( + result, + mail, + device, + auth_clone, + set_error, + set_loading, + ) + .await { - web_sys::console::warn_1( - &format!("Failed to set current principal: {}", e).into(), - ); - } - // Store server URL in localStorage (not sensitive) - if let Some(window) = web_sys::window() { - if let Ok(Some(ls)) = window.local_storage() { - let _ = ls.set_item("zopp_server_url", &result.server_url); + // Navigate on success - use window.location for simplicity + #[cfg(target_arch = "wasm32")] + if let Some(window) = web_sys::window() { + let _ = window.location().set_href("/workspaces"); } } } - - // Set auth state with credentials - auth_clone.set_authenticated( - crate::state::auth::Principal { - id: result.principal_id.clone(), - name: device.clone(), - email: Some(mail.clone()), - user_id: Some(result.user_id.clone()), - }, - crate::state::auth::Credentials { - principal_id: result.principal_id, - ed25519_private_key: result.ed25519_private_key, - x25519_private_key: result.x25519_private_key, - server_url: result.server_url, - }, - ); - - // Navigate to workspaces - navigate_clone("/workspaces", Default::default()); } Err(e) => { set_error.set(Some(format!("Join failed: {}", e))); @@ -112,85 +102,284 @@ pub fn RegisterPage() -> impl IntoView { }); }; - // Allow joining with invite even when authenticated - // This enables adding another principal - let _ = navigate_for_effect; // Suppress unused warning + let on_verify = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + let code = verification_code.get(); + + if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) { + set_verification_error.set(Some("Please enter a 6-digit code".to_string())); + return; + } + + #[allow(unused_variables)] // Used in wasm32 target + let pending = match pending_verification.get() { + Some(p) => p, + None => return, + }; + + set_verifying.set(true); + set_verification_error.set(None); + set_verification_success.set(None); + + let auth_clone = auth_for_complete; + + spawn_local(async move { + #[cfg(target_arch = "wasm32")] + { + match verify_email_code(&pending.email, &code, &pending.result).await { + Ok(verify_result) => { + if verify_result.success { + // Verification successful - update result with principal_id from verify response + let mut updated_result = pending.result.clone(); + updated_result.principal_id = verify_result.principal_id; + if let Some(user_id) = verify_result.user_id { + updated_result.user_id = user_id; + } + + // Complete registration with updated result + if complete_registration_impl( + updated_result, + pending.email, + pending.device_name, + auth_clone, + set_verification_error, + set_verifying, + ) + .await + { + // Navigate on success + if let Some(window) = web_sys::window() { + let _ = window.location().set_href("/workspaces"); + } + } + // Note: complete_registration_impl sets error and loading state on failure + } else { + let err_msg = + match (verify_result.message, verify_result.attempts_remaining) { + (Some(msg), Some(attempts)) => { + format!("{} ({} attempts remaining)", msg, attempts) + } + (Some(msg), None) => msg, + (None, Some(attempts)) => { + format!("Invalid code. {} attempts remaining.", attempts) + } + (None, None) => "Invalid verification code".to_string(), + }; + set_verification_error.set(Some(err_msg)); + set_verifying.set(false); + } + } + Err(e) => { + set_verification_error.set(Some(e)); + set_verifying.set(false); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = auth_clone; + set_verification_error.set(Some("Not available on server".to_string())); + set_verifying.set(false); + } + }); + }; + + let on_resend = move |_| { + #[allow(unused_variables)] // Used in wasm32 target + let pending = match pending_verification.get() { + Some(p) => p, + None => return, + }; + + set_resending.set(true); + set_verification_error.set(None); + set_verification_success.set(None); + + spawn_local(async move { + #[cfg(target_arch = "wasm32")] + { + match resend_verification_email(&pending.email, &pending.result.server_url).await { + Ok(msg) => { + set_verification_success.set(Some(msg)); + set_resending.set(false); + } + Err(e) => { + set_verification_error.set(Some(e)); + set_resending.set(false); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + { + set_verification_error.set(Some("Not available on server".to_string())); + set_resending.set(false); + } + }); + }; view! {
-

"Join Workspace"

-

- "Create a new principal using an invite token." -

- -
- -
- - - - {move || error.get().unwrap_or_default()} + "Join Workspace" +

+ "Create a new principal using an invite token." +

+ + + +
+ + + + {move || error.get().unwrap_or_default()} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
"OR"
+ + + "Import Existing Principal" + + } + } + > + // Email verification UI +

"Verify Your Email"

+

+ "We sent a verification code to " + + {move || pending_verification.get().map(|p| p.email).unwrap_or_default()} + +

+ +
+ +
+ + + + {move || verification_error.get().unwrap_or_default()} +
+
+ + +
+ + + + {move || verification_success.get().unwrap_or_default()} +
+
+ +
+ +
- - -
- - -
- -
- - -
- -
- - -
+ + +
+ +
"Didn't receive the code?"
- - -
"OR"
- - "Import Existing Principal" - + +
@@ -198,15 +387,99 @@ pub fn RegisterPage() -> impl IntoView { } /// Result of joining a workspace +#[derive(Clone)] #[allow(dead_code)] struct JoinResult { - principal_id: String, + principal_id: Option, // None when verification_required=true user_id: String, + principal_name: String, ed25519_private_key: String, ed25519_public_key: String, x25519_private_key: String, x25519_public_key: String, + // KEK wrapping data for workspace invites + ephemeral_pub: Vec, + kek_wrapped: Vec, + kek_nonce: Vec, server_url: String, + verification_required: bool, +} + +/// Complete registration by storing credentials and setting auth state. +/// Returns true on success, false on failure (error will be set). +#[allow(clippy::too_many_arguments)] +async fn complete_registration_impl( + result: JoinResult, + mail: String, + device: String, + auth: crate::state::auth::AuthContext, + set_error: WriteSignal>, + set_loading: WriteSignal, +) -> bool { + // Extract principal_id - must be present at this point + let principal_id = match &result.principal_id { + Some(id) => id.clone(), + None => { + set_error.set(Some( + "Missing principal_id in registration result".to_string(), + )); + set_loading.set(false); + return false; + } + }; + + #[cfg(target_arch = "wasm32")] + { + let storage = IndexedDbStorage::new(); + let stored = StoredPrincipal { + id: principal_id.clone(), + name: device.clone(), + email: Some(mail.clone()), + user_id: Some(result.user_id.clone()), + ed25519_private_key: result.ed25519_private_key.clone(), + ed25519_public_key: result.ed25519_public_key.clone(), + x25519_private_key: Some(result.x25519_private_key.clone()), + x25519_public_key: Some(result.x25519_public_key.clone()), + ed25519_nonce: None, + x25519_nonce: None, + encrypted: false, + }; + if let Err(e) = storage.store_principal(stored).await { + set_error.set(Some(format!("Failed to store principal: {}", e))); + set_loading.set(false); + return false; + } + if let Err(e) = storage.set_current_principal_id(Some(&principal_id)).await { + web_sys::console::warn_1(&format!("Failed to set current principal: {}", e).into()); + } + if let Some(window) = web_sys::window() { + if let Ok(Some(ls)) = window.local_storage() { + let _ = ls.set_item("zopp_server_url", &result.server_url); + } + } + } + + auth.set_authenticated( + crate::state::auth::Principal { + id: principal_id.clone(), + name: device.clone(), + email: Some(mail.clone()), + user_id: Some(result.user_id.clone()), + }, + crate::state::auth::Credentials { + principal_id, + ed25519_private_key: result.ed25519_private_key, + x25519_private_key: result.x25519_private_key, + server_url: result.server_url, + }, + ); + + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (set_error, set_loading); + } + + true } /// Join a workspace using an invite token @@ -308,9 +581,9 @@ async fn join_workspace( principal_name: principal_name.to_string(), public_key, x25519_public_key: x25519_public_bytes, - ephemeral_pub, - kek_wrapped, - kek_nonce, + ephemeral_pub: ephemeral_pub.clone(), + kek_wrapped: kek_wrapped.clone(), + kek_nonce: kek_nonce.clone(), }) .await .map_err(|e| format!("Join failed: {}", e))?; @@ -318,11 +591,16 @@ async fn join_workspace( Ok(JoinResult { principal_id: response.principal_id, user_id: response.user_id, + principal_name: principal_name.to_string(), ed25519_private_key: hex::encode(signing_key.to_bytes()), ed25519_public_key: hex::encode(verifying_key.to_bytes()), x25519_private_key: hex::encode(x25519_keypair.secret_key_bytes()), x25519_public_key: hex::encode(x25519_keypair.public_key_bytes()), + ephemeral_pub, + kek_wrapped, + kek_nonce, server_url, + verification_required: response.verification_required, }) } @@ -332,3 +610,89 @@ async fn join_workspace( Err("Not available on server".to_string()) } } + +/// Result of email verification +#[cfg(target_arch = "wasm32")] +struct VerifyResult { + success: bool, + message: Option, + attempts_remaining: Option, + principal_id: Option, + user_id: Option, +} + +/// Verify email with a code - creates principal on success +#[cfg(target_arch = "wasm32")] +async fn verify_email_code( + email: &str, + code: &str, + join_result: &JoinResult, +) -> Result { + use zopp_proto_web::{VerifyEmailRequest, ZoppWebClient}; + + let client = ZoppWebClient::new(&join_result.server_url); + + // Decode keys from hex for the request + let public_key = hex::decode(&join_result.ed25519_public_key) + .map_err(|e| format!("Invalid public key hex: {}", e))?; + let x25519_public_key = hex::decode(&join_result.x25519_public_key) + .map_err(|e| format!("Invalid x25519 public key hex: {}", e))?; + + let response = client + .verify_email(VerifyEmailRequest { + email: email.to_string(), + code: code.to_string(), + principal_name: join_result.principal_name.clone(), + public_key, + x25519_public_key, + ephemeral_pub: join_result.ephemeral_pub.clone(), + kek_wrapped: join_result.kek_wrapped.clone(), + kek_nonce: join_result.kek_nonce.clone(), + }) + .await + .map_err(|e| format!("Verification failed: {}", e))?; + + Ok(VerifyResult { + success: response.success, + message: if response.message.is_empty() { + None + } else { + Some(response.message) + }, + attempts_remaining: if response.attempts_remaining > 0 { + Some(response.attempts_remaining as u32) + } else { + None + }, + principal_id: if response.principal_id.is_empty() { + None + } else { + Some(response.principal_id) + }, + user_id: if response.user_id.is_empty() { + None + } else { + Some(response.user_id) + }, + }) +} + +/// Resend verification email +#[cfg(target_arch = "wasm32")] +async fn resend_verification_email(email: &str, server_url: &str) -> Result { + use zopp_proto_web::{ResendVerificationRequest, ZoppWebClient}; + + let client = ZoppWebClient::new(server_url); + let response = client + .resend_verification(ResendVerificationRequest { + email: email.to_string(), + }) + .await + .map_err(|e| format!("Resend failed: {}", e))?; + + if response.success { + Ok(response.message) + } else { + Err(response.message) + } +} diff --git a/apps/zopp-web/tests/fixtures/mock-smtp.ts b/apps/zopp-web/tests/fixtures/mock-smtp.ts new file mode 100644 index 00000000..0b3ba4de --- /dev/null +++ b/apps/zopp-web/tests/fixtures/mock-smtp.ts @@ -0,0 +1,149 @@ +/** + * Mock SMTP server for capturing verification emails in E2E tests. + * + * Uses smtp-server to create a real SMTP server that captures emails, + * allowing tests to retrieve verification codes from the email body. + * + * This is a TypeScript equivalent of the Rust mock SMTP in e2e-tests. + * We need a separate implementation because Playwright tests run in Node.js. + */ + +import { SMTPServer, SMTPServerAddress, SMTPServerSession, SMTPServerDataStream } from 'smtp-server'; +import { simpleParser, ParsedMail } from 'mailparser'; +import * as net from 'net'; + +export interface CapturedEmail { + from: string; + to: string[]; + subject: string; + text: string; + html: string | false; +} + +export class MockSmtpServer { + private server: SMTPServer; + private emails: CapturedEmail[] = []; + private port: number = 0; + private _isRunning: boolean = false; + + constructor() { + this.server = new SMTPServer({ + authOptional: true, + disabledCommands: ['STARTTLS', 'AUTH'], + onData: (stream: SMTPServerDataStream, session: SMTPServerSession, callback: (err?: Error | null) => void) => { + this.handleData(stream, session).then(() => callback()).catch(callback); + }, + onMailFrom: (address: SMTPServerAddress, session: SMTPServerSession, callback: (err?: Error | null) => void) => { + callback(); + }, + onRcptTo: (address: SMTPServerAddress, session: SMTPServerSession, callback: (err?: Error | null) => void) => { + callback(); + }, + }); + } + + private async handleData(stream: SMTPServerDataStream, session: SMTPServerSession): Promise { + const parsed: ParsedMail = await simpleParser(stream); + + const email: CapturedEmail = { + from: session.envelope.mailFrom ? (session.envelope.mailFrom as SMTPServerAddress).address : '', + to: session.envelope.rcptTo.map((r: SMTPServerAddress) => r.address), + subject: parsed.subject || '', + text: parsed.text || '', + html: parsed.html || false, + }; + + this.emails.push(email); + } + + private async findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') { + const port = address.port; + server.close(() => resolve(port)); + } else { + reject(new Error('Failed to get port')); + } + }); + server.on('error', reject); + }); + } + + async start(): Promise { + this.port = await this.findAvailablePort(); + + return new Promise((resolve, reject) => { + this.server.listen(this.port, '127.0.0.1', () => { + this._isRunning = true; + resolve(this.port); + }); + this.server.on('error', reject); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + this._isRunning = false; + this.server.close(() => resolve()); + }); + } + + getPort(): number { + return this.port; + } + + isRunning(): boolean { + return this._isRunning; + } + + getEmails(): CapturedEmail[] { + return [...this.emails]; + } + + getEmailFor(toEmail: string): CapturedEmail | undefined { + return [...this.emails].reverse().find(e => + e.to.some(t => t.toLowerCase().includes(toEmail.toLowerCase())) + ); + } + + /** + * Extract verification code from the latest email to an address. + * Looks for a 6-digit code in the email body. + */ + getVerificationCode(toEmail: string): string | null { + const email = this.getEmailFor(toEmail); + if (!email) return null; + + const match = email.text.match(/\b(\d{6})\b/); + return match ? match[1] : null; + } + + clear(): void { + this.emails = []; + } + + async waitForEmail(timeoutMs: number = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (this.emails.length > 0) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; + } + + async waitForEmailTo(toEmail: string, timeoutMs: number = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (this.getEmailFor(toEmail)) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; + } +} diff --git a/apps/zopp-web/tests/fixtures/test-setup.ts b/apps/zopp-web/tests/fixtures/test-setup.ts index e28e60db..49277faf 100644 --- a/apps/zopp-web/tests/fixtures/test-setup.ts +++ b/apps/zopp-web/tests/fixtures/test-setup.ts @@ -22,6 +22,47 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +/** + * Get verification code from MailHog API. + * Waits for email to arrive and extracts the 6-digit code. + */ +async function getVerificationCodeFromMailHog(apiUrl: string, toEmail: string, timeoutMs = 10000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + try { + // Use AbortController to ensure fetch honors overall timeout + const controller = new AbortController(); + const fetchTimeout = setTimeout(() => controller.abort(), 5000); + const response = await fetch(`${apiUrl}/messages`, { signal: controller.signal }); + clearTimeout(fetchTimeout); + if (!response.ok) continue; + + const data = await response.json(); + // Find email sent to this address (most recent first) + const email = data.items?.reverse().find((msg: { To?: Array<{ Mailbox: string; Domain: string }> }) => + msg.To?.some(to => `${to.Mailbox}@${to.Domain}`.toLowerCase() === toEmail.toLowerCase()) + ); + + if (email) { + // Extract 6-digit code from email body + const body = email.Content?.Body || ''; + const match = body.match(/\b(\d{6})\b/); + if (match) { + return match[1]; + } + } + } catch { + // Retry on error + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, 500)); + } + + return null; +} + // Credentials structure matching what the web app expects interface StoredCredentials { principal_id: string; @@ -144,11 +185,47 @@ export async function setupTestData(serverUrl: string): Promise { const email = `${testId}@example.com`; const principalDeviceName = `${testId}-device`; - // Create test user - execSync(`${cliBin} --server ${serverUrl} --use-file-storage join "${inviteToken}" ${email} --principal ${principalDeviceName}`, { - env: { ...process.env, HOME: userHomeDir }, - stdio: 'pipe', - }); + // Check if email verification is enabled by environment variable + const mailhogApiUrl = process.env.MAILHOG_API_URL; + const isVerificationEnabled = !!mailhogApiUrl; + + if (isVerificationEnabled) { + // Email verification flow: + // 1. First join attempt with invalid code triggers verification email + // 2. Get code from MailHog + // 3. Join with correct code + + // Step 1: Trigger verification email (use invalid code to fail but still trigger email send) + try { + execSync(`${cliBin} --server ${serverUrl} --use-file-storage join "${inviteToken}" ${email} --principal ${principalDeviceName} --verification-code 000000`, { + env: { ...process.env, HOME: userHomeDir }, + stdio: 'pipe', + maxBuffer: 10 * 1024 * 1024, // 10MB buffer + }); + } catch { + // Expected to fail with invalid code - but email should be sent + } + + // Step 2: Get verification code from MailHog + const verificationCode = await getVerificationCodeFromMailHog(mailhogApiUrl, email); + if (!verificationCode) { + throw new Error(`Failed to get verification code from MailHog for ${email}`); + } + + // Step 3: Join with correct code + execSync(`${cliBin} --server ${serverUrl} --use-file-storage join "${inviteToken}" ${email} --principal ${principalDeviceName} --verification-code ${verificationCode}`, { + env: { ...process.env, HOME: userHomeDir }, + stdio: 'pipe', + maxBuffer: 10 * 1024 * 1024, + }); + } else { + // No verification - direct join + execSync(`${cliBin} --server ${serverUrl} --use-file-storage join "${inviteToken}" ${email} --principal ${principalDeviceName}`, { + env: { ...process.env, HOME: userHomeDir }, + stdio: 'pipe', + maxBuffer: 10 * 1024 * 1024, + }); + } // Read credentials from CLI config const configPath = path.join(userHomeDir, '.zopp', 'config.json'); diff --git a/apps/zopp-web/tests/fixtures/verification-setup.ts b/apps/zopp-web/tests/fixtures/verification-setup.ts new file mode 100644 index 00000000..25b8a873 --- /dev/null +++ b/apps/zopp-web/tests/fixtures/verification-setup.ts @@ -0,0 +1,222 @@ +/** + * Test setup for email verification E2E tests. + * + * This setup: + * 1. Starts a mock SMTP server + * 2. Starts zopp-server with email verification enabled + * 3. Starts Envoy gRPC-web proxy + * 4. Provides helpers to get verification codes from captured emails + */ + +import { test as base, Page } from '@playwright/test'; +import { execSync, execFileSync, spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as net from 'net'; +import { MockSmtpServer } from './mock-smtp'; + +// Find an available port +async function findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') { + const port = address.port; + server.close(() => resolve(port)); + } else { + reject(new Error('Failed to get port')); + } + }); + server.on('error', reject); + }); +} + +// Wait for a server to be ready +async function waitForServer(url: string, timeoutMs: number = 30000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + // Only return true if server responds successfully + if (response.ok) { + return true; + } + } catch { + // Retry on network error + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + return false; +} + +export interface VerificationTestContext { + serverUrl: string; + grpcWebUrl: string; + testDir: string; + inviteToken: string; + mockSmtp: MockSmtpServer; + serverProcess: ChildProcess; + envoyProcess: ChildProcess | null; + dbPath: string; +} + +/** + * Setup test environment with email verification enabled. + * Starts zopp-server, mock SMTP, and optionally Envoy proxy. + */ +export async function setupVerificationTest(): Promise { + // Find project root + const projectRoot = path.resolve(__dirname, '../../../..'); + + // Find binaries + let serverBin = path.join(projectRoot, 'target/debug/zopp-server'); + if (!fs.existsSync(serverBin)) { + serverBin = path.join(projectRoot, 'target/release/zopp-server'); + } + if (!fs.existsSync(serverBin)) { + throw new Error('zopp-server binary not found. Run: cargo build'); + } + + // Create temp directory + const testId = `web-verify-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const testDir = path.join(os.tmpdir(), testId); + fs.mkdirSync(testDir, { recursive: true }); + + // Create SQLite database path + const dbPath = path.join(testDir, 'zopp.db'); + const dbUrl = `sqlite://${dbPath}?mode=rwc`; + + // Start mock SMTP server + const mockSmtp = new MockSmtpServer(); + const smtpPort = await mockSmtp.start(); + + // Find available ports + const serverPort = await findAvailablePort(); + const healthPort = await findAvailablePort(); + const envoyPort = await findAvailablePort(); + + // Start zopp-server with email verification enabled + const serverEnv = { + ...process.env, + DATABASE_URL: dbUrl, + ZOPP_EMAIL_VERIFICATION_REQUIRED: 'true', + ZOPP_EMAIL_PROVIDER: 'smtp', + SMTP_HOST: '127.0.0.1', + SMTP_PORT: smtpPort.toString(), + SMTP_USE_TLS: 'false', + ZOPP_EMAIL_FROM: 'test@example.com', + }; + + const serverProcess = spawn(serverBin, [ + 'serve', + '--addr', `0.0.0.0:${serverPort}`, + '--health-addr', `0.0.0.0:${healthPort}`, + ], { + env: serverEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Log server output for debugging + serverProcess.stdout?.on('data', (data) => { + if (process.env.DEBUG) console.log(`[server] ${data}`); + }); + serverProcess.stderr?.on('data', (data) => { + if (process.env.DEBUG) console.error(`[server] ${data}`); + }); + + // Wait for server to be ready + const serverReady = await waitForServer(`http://127.0.0.1:${healthPort}/readyz`); + if (!serverReady) { + serverProcess.kill(); + await mockSmtp.stop(); + throw new Error('Server failed to start within timeout'); + } + + // Create an invite token + const inviteToken = execSync(`${serverBin} invite create --expires-hours 1 --plain`, { + env: { ...process.env, DATABASE_URL: dbUrl }, + }).toString().trim(); + + // For now, skip Envoy and test against gRPC directly + // In production tests, you'd start Envoy here + const envoyProcess = null; + + return { + serverUrl: `http://127.0.0.1:${serverPort}`, + grpcWebUrl: `http://127.0.0.1:${serverPort}`, // Use server port directly since Envoy isn't running + testDir, + inviteToken, + mockSmtp, + serverProcess, + envoyProcess, + dbPath, + }; +} + +/** + * Cleanup test environment + */ +export async function teardownVerificationTest(ctx: VerificationTestContext): Promise { + // Stop server + if (ctx.serverProcess) { + ctx.serverProcess.kill('SIGTERM'); + // Give it time to shutdown gracefully + await new Promise(resolve => setTimeout(resolve, 500)); + ctx.serverProcess.kill('SIGKILL'); + } + + // Stop Envoy if running + if (ctx.envoyProcess) { + ctx.envoyProcess.kill('SIGTERM'); + await new Promise(resolve => setTimeout(resolve, 200)); + ctx.envoyProcess.kill('SIGKILL'); + } + + // Stop mock SMTP + await ctx.mockSmtp.stop(); + + // Cleanup test directory + try { + fs.rmSync(ctx.testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +} + +/** + * Check if a verification record exists in the database. + * Note: We store hashes (zero-knowledge), so we can't retrieve the plaintext code. + */ +export function hasVerificationRecord(dbPath: string, email: string): boolean { + try { + // Use execFileSync to avoid shell injection - email is passed via SQL query directly + // Escape single quotes in email for SQL safety + const escapedEmail = email.replace(/'/g, "''"); + const result = execFileSync( + 'sqlite3', + [dbPath, `SELECT COUNT(*) FROM email_verifications WHERE email = '${escapedEmail}';`], + { encoding: 'utf-8' } + ).trim(); + return parseInt(result, 10) > 0; + } catch { + return false; + } +} + +// Extended test fixture for verification tests +export const verificationTest = base.extend<{ + verificationContext: VerificationTestContext; +}>({ + verificationContext: [async ({}, use) => { + const ctx = await setupVerificationTest(); + await use(ctx); + await teardownVerificationTest(ctx); + }, { scope: 'worker', timeout: 60000 }], +}); + +export { expect } from '@playwright/test'; diff --git a/apps/zopp-web/tests/join-verification.spec.ts b/apps/zopp-web/tests/join-verification.spec.ts new file mode 100644 index 00000000..3ce56b53 --- /dev/null +++ b/apps/zopp-web/tests/join-verification.spec.ts @@ -0,0 +1,309 @@ +/** + * Email Verification E2E tests for the web app. + * + * Tests the complete join flow with email verification: + * 1. User fills in the join form (invite token, email, device name) + * 2. Server requires email verification -> verification code UI appears + * 3. User enters verification code from email + * 4. Registration completes and user is authenticated + * + * Prerequisites: + * # Start MailHog (shared with CLI E2E tests) + * docker compose -f docker/docker-compose.test.yaml up -d + * + * # Start the backend services with verification enabled + * ZOPP_EMAIL_VERIFICATION_REQUIRED=true \ + * ZOPP_EMAIL_PROVIDER=smtp \ + * SMTP_HOST=127.0.0.1 \ + * SMTP_PORT=1025 \ + * SMTP_USE_TLS=false \ + * ZOPP_EMAIL_FROM=test@example.com \ + * cargo run --bin zopp-server serve + * + * # Run the web UI + * cd apps/zopp-web && npm run dev + * + * # Run the tests + * cd apps/zopp-web && npm run test:e2e -- --grep verification + * + * Note: These tests share MailHog with the CLI E2E tests (docker/docker-compose.test.yaml) + */ + +import { test, expect, Page } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Configuration +const GRPC_WEB_URL = process.env.ZOPP_GRPC_WEB_URL || 'http://localhost:8080'; +const MAILHOG_API_URL = process.env.MAILHOG_API_URL || 'http://localhost:8025/api/v2'; + +interface MailHogMessage { + ID: string; + From: { Mailbox: string; Domain: string }; + To: Array<{ Mailbox: string; Domain: string }>; + Content: { + Headers: Record; + Body: string; + }; +} + +interface MailHogResponse { + total: number; + count: number; + start: number; + items: MailHogMessage[]; +} + +/** + * Check if the verification test stack is running + */ +async function isVerificationStackRunning(): Promise { + try { + // Check if Envoy is responding + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + await fetch(GRPC_WEB_URL, { method: 'OPTIONS', signal: controller.signal }); + clearTimeout(timeoutId); + return true; + } catch { + return false; + } +} + +/** + * Create an invite token using the server binary or docker exec + */ +async function createInviteToken(): Promise { + // First check if ZOPP_VERIFICATION_TEST_INVITE is set + if (process.env.ZOPP_VERIFICATION_TEST_INVITE) { + return process.env.ZOPP_VERIFICATION_TEST_INVITE; + } + + // Try to find server binary + const projectRoot = path.resolve(__dirname, '../../..'); + const serverBin = fs.existsSync(path.join(projectRoot, 'target/debug/zopp-server')) + ? path.join(projectRoot, 'target/debug/zopp-server') + : path.join(projectRoot, 'target/release/zopp-server'); + + // Try to find the database + const dbPath = path.join(projectRoot, 'zopp.db'); + if (fs.existsSync(serverBin) && fs.existsSync(dbPath)) { + try { + return execSync(`${serverBin} invite create --expires-hours 1 --plain`, { + env: { ...process.env, DATABASE_URL: `sqlite://${dbPath}` }, + }).toString().trim(); + } catch { + // Fall through to docker exec + } + } + + // Try docker exec + try { + return execSync( + 'docker compose -f docker/docker-compose.web-verification-test.yaml exec -T zopp-server /app/zopp-server invite create --expires-hours 1 --plain', + { cwd: projectRoot } + ).toString().trim(); + } catch (e) { + throw new Error( + 'Could not create invite token. Either:\n' + + '1. Set ZOPP_VERIFICATION_TEST_INVITE environment variable\n' + + '2. Start the verification test stack: docker compose -f docker/docker-compose.web-verification-test.yaml up -d\n' + + `Error: ${e}` + ); + } +} + +/** + * Get verification code from MailHog API + */ +async function getVerificationCodeFromMailhog(email: string, timeoutMs: number = 10000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(`${MAILHOG_API_URL}/messages`); + if (!response.ok) { + await new Promise(resolve => setTimeout(resolve, 500)); + continue; + } + + const data: MailHogResponse = await response.json(); + + // Find email to the target address + const message = data.items.find(msg => + msg.To.some(to => `${to.Mailbox}@${to.Domain}`.toLowerCase() === email.toLowerCase()) + ); + + if (message) { + // Extract 6-digit code from email body + const match = message.Content.Body.match(/\b(\d{6})\b/); + if (match) { + return match[1]; + } + } + } catch { + // Retry on error + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + throw new Error(`No verification email found for ${email} within ${timeoutMs}ms`); +} + +/** + * Clear all emails from MailHog + */ +async function clearMailhog(): Promise { + try { + await fetch(`${MAILHOG_API_URL}/messages`, { method: 'DELETE' }); + } catch { + // Ignore errors + } +} + +test.describe('Join with Email Verification', () => { + test.beforeAll(async () => { + // Check if verification stack is running + const isRunning = await isVerificationStackRunning(); + if (!isRunning) { + console.log(''); + console.log('⚠️ Verification test stack not running. Skipping verification tests.'); + console.log(' Start with: docker compose -f docker/docker-compose.web-verification-test.yaml up -d'); + console.log(''); + test.skip(); + } + }); + + test.beforeEach(async () => { + // Clear MailHog before each test + await clearMailhog(); + }); + + test('should show verification code input after submitting join form', async ({ page }) => { + const inviteToken = await createInviteToken(); + const testEmail = `test-${Date.now()}@example.com`; + const deviceName = 'Test Device'; + + // Navigate to join/invite page + await page.goto('/invite'); + + // Fill in the form + await page.getByPlaceholder(/zopp-invite/i).fill(inviteToken); + await page.getByPlaceholder(/you@example.com/i).fill(testEmail); + await page.getByPlaceholder(/My Laptop/i).fill(deviceName); + + // Submit the form + await page.getByRole('button', { name: /Create Principal/i }).click(); + + // Should show verification code input (use heading which is unique) + await expect(page.getByRole('heading', { name: /Verify Your Email/i })).toBeVisible({ timeout: 10000 }); + await expect(page.getByPlaceholder('123456')).toBeVisible(); + }); + + test('should complete registration with valid verification code', async ({ page }) => { + const inviteToken = await createInviteToken(); + const testEmail = `test-${Date.now()}@example.com`; + const deviceName = 'Test Device'; + + // Navigate to join/invite page + await page.goto('/invite'); + + // Fill in the form + await page.getByPlaceholder(/zopp-invite/i).fill(inviteToken); + await page.getByPlaceholder(/you@example.com/i).fill(testEmail); + await page.getByPlaceholder(/My Laptop/i).fill(deviceName); + + // Submit the form + await page.getByRole('button', { name: /Create Principal/i }).click(); + + // Wait for verification code input to appear (use heading which is unique) + await expect(page.getByRole('heading', { name: /Verify Your Email/i })).toBeVisible({ timeout: 10000 }); + + // Get verification code from MailHog + const verificationCode = await getVerificationCodeFromMailhog(testEmail); + expect(verificationCode).toMatch(/^\d{6}$/); + + // Enter the verification code + await page.getByPlaceholder('123456').fill(verificationCode); + + // Submit verification + await page.getByRole('button', { name: /Verify/i }).click(); + + // Should redirect to workspaces page on success + await expect(page).toHaveURL(/\/workspaces/, { timeout: 10000 }); + }); + + test('should show error for invalid verification code', async ({ page }) => { + const inviteToken = await createInviteToken(); + const testEmail = `test-${Date.now()}@example.com`; + const deviceName = 'Test Device'; + + // Navigate to join/invite page + await page.goto('/invite'); + + // Fill in the form + await page.getByPlaceholder(/zopp-invite/i).fill(inviteToken); + await page.getByPlaceholder(/you@example.com/i).fill(testEmail); + await page.getByPlaceholder(/My Laptop/i).fill(deviceName); + + // Submit the form + await page.getByRole('button', { name: /Create Principal/i }).click(); + + // Wait for verification code input to appear (use heading which is unique) + await expect(page.getByRole('heading', { name: /Verify Your Email/i })).toBeVisible({ timeout: 10000 }); + + // Enter an invalid code + await page.getByPlaceholder('123456').fill('000000'); + + // Submit verification + await page.getByRole('button', { name: /Verify/i }).click(); + + // Should show error message + await expect(page.getByText(/invalid|incorrect|wrong|attempts/i)).toBeVisible({ timeout: 5000 }); + + // Should still be on the same page (not redirected) + await expect(page).toHaveURL(/\/invite/); + }); + + test('should allow retrying with correct code after invalid attempt', async ({ page }) => { + const inviteToken = await createInviteToken(); + const testEmail = `test-${Date.now()}@example.com`; + const deviceName = 'Test Device'; + + // Navigate to join/invite page + await page.goto('/invite'); + + // Fill in the form + await page.getByPlaceholder(/zopp-invite/i).fill(inviteToken); + await page.getByPlaceholder(/you@example.com/i).fill(testEmail); + await page.getByPlaceholder(/My Laptop/i).fill(deviceName); + + // Submit the form + await page.getByRole('button', { name: /Create Principal/i }).click(); + + // Wait for verification code input (use heading which is unique) + await expect(page.getByRole('heading', { name: /Verify Your Email/i })).toBeVisible({ timeout: 10000 }); + + // First attempt with invalid code + await page.getByPlaceholder('123456').fill('000000'); + await page.getByRole('button', { name: /Verify/i }).click(); + + // Should show error + await expect(page.getByText(/invalid|incorrect|wrong|attempts/i)).toBeVisible({ timeout: 5000 }); + + // Get the real verification code + const verificationCode = await getVerificationCodeFromMailhog(testEmail); + + // Clear and enter correct code + await page.getByPlaceholder('123456').clear(); + await page.getByPlaceholder('123456').fill(verificationCode); + + // Submit again + await page.getByRole('button', { name: /Verify/i }).click(); + + // Should redirect to workspaces page on success + await expect(page).toHaveURL(/\/workspaces/, { timeout: 10000 }); + }); +}); diff --git a/crates/zopp-crypto/Cargo.toml b/crates/zopp-crypto/Cargo.toml index 403c6024..cfca239e 100644 --- a/crates/zopp-crypto/Cargo.toml +++ b/crates/zopp-crypto/Cargo.toml @@ -14,6 +14,7 @@ wasm = ["getrandom/js", "wasm-bindgen", "js-sys"] [dependencies] argon2 = { workspace = true } chacha20poly1305 = { workspace = true } +hex = { workspace = true } rand_core = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } diff --git a/crates/zopp-crypto/src/lib.rs b/crates/zopp-crypto/src/lib.rs index 603ca694..f0431d28 100644 --- a/crates/zopp-crypto/src/lib.rs +++ b/crates/zopp-crypto/src/lib.rs @@ -20,6 +20,45 @@ pub enum KdfError { const MIB: u32 = 1024; const MEMORY_COST_KIB: u32 = 64 * MIB; +/// Hash data using Argon2id with a salt. +/// Returns hex-encoded 32-byte hash. +/// +/// This is a general-purpose Argon2id hash suitable for verification codes, +/// tokens, or any data that needs deterministic hashing with a salt. +pub fn argon2_hash(data: &[u8], salt: &[u8]) -> Result { + let params = + argon2::Params::new(MEMORY_COST_KIB, 3, 1, Some(32)).map_err(KdfError::InvalidParams)?; + + let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + + let mut hash = Zeroizing::new([0u8; 32]); + + argon2 + .hash_password_into(data, salt, hash.as_mut()) + .map_err(KdfError::DerivationFailed)?; + + Ok(hex::encode(hash.as_ref())) +} + +/// Hash data using Argon2id, returning raw bytes instead of hex. +/// Returns 32-byte hash wrapped in Zeroizing for security. +/// +/// Use this when you need the raw key bytes (e.g., for encryption). +pub fn argon2_hash_raw(data: &[u8], salt: &[u8]) -> Result, KdfError> { + let params = + argon2::Params::new(MEMORY_COST_KIB, 3, 1, Some(32)).map_err(KdfError::InvalidParams)?; + + let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + + let mut hash = Zeroizing::new([0u8; 32]); + + argon2 + .hash_password_into(data, salt, hash.as_mut()) + .map_err(KdfError::DerivationFailed)?; + + Ok(hash) +} + /// Derive master key from passphrase pub fn derive_master_key(pass: &str, salt: &[u8]) -> Result { let mut key = Zeroizing::new([0u8; 32]); @@ -437,4 +476,54 @@ mod tests { let bob_shared = bob.shared_secret(alice.public_key()); assert!(unwrap_key(&wrapped.0, &nonce, &bob_shared, b"bad-aad").is_err()); } + + // ───────────────────────────── Argon2 Hash Tests ───────────────────────────── + + #[test] + fn argon2_hash_is_deterministic() { + let data = b"123456"; + let salt = b"test@example.com"; + + let hash1 = argon2_hash(data, salt).unwrap(); + let hash2 = argon2_hash(data, salt).unwrap(); + + assert_eq!(hash1, hash2, "Same input should produce same hash"); + } + + #[test] + fn argon2_hash_different_inputs() { + let salt = b"test@example.com"; + + let hash1 = argon2_hash(b"123456", salt).unwrap(); + let hash2 = argon2_hash(b"654321", salt).unwrap(); + + assert_ne!( + hash1, hash2, + "Different inputs should produce different hashes" + ); + } + + #[test] + fn argon2_hash_different_salts() { + let data = b"123456"; + + let hash1 = argon2_hash(data, b"test@example.com").unwrap(); + let hash2 = argon2_hash(data, b"other@example.com").unwrap(); + + assert_ne!( + hash1, hash2, + "Different salts should produce different hashes" + ); + } + + #[test] + fn argon2_hash_raw_is_deterministic() { + let data = b"123456"; + let salt = b"test@example.com"; + + let hash1 = argon2_hash_raw(data, salt).unwrap(); + let hash2 = argon2_hash_raw(data, salt).unwrap(); + + assert_eq!(*hash1, *hash2, "Same input should produce same hash"); + } } diff --git a/crates/zopp-proto-web/src/lib.rs b/crates/zopp-proto-web/src/lib.rs index eaa63110..977a53f4 100644 --- a/crates/zopp-proto-web/src/lib.rs +++ b/crates/zopp-proto-web/src/lib.rs @@ -496,6 +496,34 @@ impl ZoppWebClient { Ok(response.into_inner()) } + // ============ Email Verification RPCs ============ + + /// Verify email with a code (unauthenticated) + pub async fn verify_email( + &self, + request: VerifyEmailRequest, + ) -> Result { + let mut client = zopp_service_client::ZoppServiceClient::new(self.client()); + let response = client + .verify_email(request) + .await + .map_err(WebClientError::from)?; + Ok(response.into_inner()) + } + + /// Resend verification email (unauthenticated) + pub async fn resend_verification( + &self, + request: ResendVerificationRequest, + ) -> Result { + let mut client = zopp_service_client::ZoppServiceClient::new(self.client()); + let response = client + .resend_verification(request) + .await + .map_err(WebClientError::from)?; + Ok(response.into_inner()) + } + /// Create an invite (authenticated) pub async fn create_invite( &self, diff --git a/crates/zopp-proto/proto/zopp.proto b/crates/zopp-proto/proto/zopp.proto index e2e4afe3..a1d615b6 100644 --- a/crates/zopp-proto/proto/zopp.proto +++ b/crates/zopp-proto/proto/zopp.proto @@ -7,6 +7,10 @@ service ZoppService { rpc Register(RegisterRequest) returns (RegisterResponse); rpc Login(LoginRequest) returns (LoginResponse); + // Email Verification + rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); + rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); + // Workspaces rpc CreateWorkspace(CreateWorkspaceRequest) returns (Workspace); rpc ListWorkspaces(Empty) returns (WorkspaceList); @@ -139,8 +143,9 @@ message JoinRequest { message JoinResponse { string user_id = 1; - string principal_id = 2; + optional string principal_id = 2; // Only set if verification_required=false repeated Workspace workspaces = 3; + bool verification_required = 4; // If true, client must complete email verification } message RegisterRequest { @@ -173,6 +178,37 @@ message LoginResponse { string principal_id = 2; } +// Email Verification messages +message VerifyEmailRequest { + string email = 1; + string code = 2; // 6-digit verification code + string principal_name = 3; // Principal name to create + bytes public_key = 4; // Ed25519 public key for authentication + bytes x25519_public_key = 5; // X25519 public key for encryption (ECDH) + // For workspace invites - KEK wrapping data + bytes ephemeral_pub = 6; // Ephemeral X25519 public key for wrapping KEK + bytes kek_wrapped = 7; // Workspace KEK wrapped for this principal + bytes kek_nonce = 8; // 24-byte nonce for wrapping +} + +message VerifyEmailResponse { + bool success = 1; + string message = 2; + int32 attempts_remaining = 3; // Number of attempts left before code is invalidated + string user_id = 4; // User ID (on success) + string principal_id = 5; // Created principal ID (on success) + repeated Workspace workspaces = 6; // Workspaces user now has access to (on success) +} + +message ResendVerificationRequest { + string email = 1; +} + +message ResendVerificationResponse { + bool success = 1; + string message = 2; +} + // Workspace messages message CreateWorkspaceRequest { string id = 1; // Client-generated workspace ID (UUID v7) diff --git a/crates/zopp-storage/src/lib.rs b/crates/zopp-storage/src/lib.rs index 63ff19ba..f11f30ae 100644 --- a/crates/zopp-storage/src/lib.rs +++ b/crates/zopp-storage/src/lib.rs @@ -52,6 +52,9 @@ pub struct GroupId(pub Uuid); #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct PrincipalExportId(pub Uuid); +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct EmailVerificationId(pub Uuid); + /// Role for RBAC permissions #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Role { @@ -291,6 +294,7 @@ pub struct CreateEnvParams { pub struct User { pub id: UserId, pub email: String, + pub verified: bool, // Email verification status pub created_at: DateTime, pub updated_at: DateTime, } @@ -319,6 +323,7 @@ pub struct Invite { pub updated_at: DateTime, pub expires_at: DateTime, pub created_by_user_id: Option, // None for server-created invites + pub consumed: bool, // Whether invite has been used } /// Workspace record @@ -411,6 +416,27 @@ pub struct CreatePrincipalExportParams { pub expires_at: DateTime, } +/// Email verification record for verifying email ownership during join +#[derive(Clone, Debug)] +pub struct EmailVerification { + pub id: EmailVerificationId, + pub email: String, // Email being verified (lowercased, unique) + pub code_hash: String, // Argon2id hash of verification code (hex-encoded, zero-knowledge) + pub invite_token: String, // Invite token to consume on verification success + pub attempts: i32, // Failed verification attempts + pub created_at: DateTime, + pub expires_at: DateTime, +} + +/// Parameters for creating/upserting an email verification +#[derive(Clone, Debug)] +pub struct CreateEmailVerificationParams { + pub email: String, // Email being verified (lowercased) + pub code_hash: String, // Argon2id hash of verification code (hex-encoded, zero-knowledge) + pub invite_token: String, // Invite token to consume on success + pub expires_at: DateTime, // When the code expires +} + /// The storage trait `zopp-core` depends on. /// /// All methods that act on project/env/secrets are **scoped by workspace**. @@ -467,6 +493,9 @@ pub trait Store: Send + Sync { /// Revoke an invite. async fn revoke_invite(&self, invite_id: &InviteId) -> Result<(), StoreError>; + /// Mark an invite as consumed (used). + async fn consume_invite(&self, token: &str) -> Result<(), StoreError>; + // ───────────────────────────────────── Principal Exports ────────────────────────────── /// Create a principal export for multi-device transfer. @@ -500,6 +529,34 @@ pub trait Store: Send + Sync { export_id: &PrincipalExportId, ) -> Result<(), StoreError>; + // ───────────────────────────────────── Email Verification ────────────────────────────── + + /// Create an email verification record. + async fn create_email_verification( + &self, + params: &CreateEmailVerificationParams, + ) -> Result; + + /// Get the latest pending email verification for an email address. + async fn get_email_verification(&self, email: &str) -> Result; + + /// Increment the failed attempts counter for an email verification. + /// Returns the new attempts count. + async fn increment_email_verification_attempts( + &self, + id: &EmailVerificationId, + ) -> Result; + + /// Delete an email verification (used after successful verification or manual cleanup). + async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError>; + + /// Delete all expired email verifications. + /// Returns the number of deleted records. + async fn cleanup_expired_email_verifications(&self) -> Result; + + /// Mark a user as verified (email ownership confirmed). + async fn mark_user_verified(&self, user_id: &UserId) -> Result<(), StoreError>; + // ───────────────────────────────────── Workspaces ───────────────────────────────────── /// Create a new workspace (returns its generated ID). @@ -1044,6 +1101,7 @@ mod tests { updated_at: Utc::now(), expires_at: Utc::now(), created_by_user_id: _params.created_by_user_id.clone(), + consumed: false, }) } @@ -1059,6 +1117,10 @@ mod tests { Ok(()) } + async fn consume_invite(&self, _token: &str) -> Result<(), StoreError> { + Ok(()) + } + async fn create_principal_export( &self, params: &CreatePrincipalExportParams, @@ -1108,6 +1170,50 @@ mod tests { Ok(()) } + async fn create_email_verification( + &self, + params: &CreateEmailVerificationParams, + ) -> Result { + Ok(EmailVerification { + id: EmailVerificationId(Uuid::new_v4()), + email: params.email.clone(), + code_hash: params.code_hash.clone(), + invite_token: params.invite_token.clone(), + attempts: 0, + created_at: Utc::now(), + expires_at: params.expires_at, + }) + } + + async fn get_email_verification( + &self, + _email: &str, + ) -> Result { + Err(StoreError::NotFound) + } + + async fn increment_email_verification_attempts( + &self, + _id: &EmailVerificationId, + ) -> Result { + Ok(1) + } + + async fn delete_email_verification( + &self, + _id: &EmailVerificationId, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn cleanup_expired_email_verifications(&self) -> Result { + Ok(0) + } + + async fn mark_user_verified(&self, _user_id: &UserId) -> Result<(), StoreError> { + Ok(()) + } + async fn create_workspace( &self, _params: &CreateWorkspaceParams, diff --git a/crates/zopp-store-postgres/.sqlx/query-5e235e795e8ae96f88596fdf40839d73534e2aa607cdf78638bc797f8aa9f6bd.json b/crates/zopp-store-postgres/.sqlx/query-08a20072e1d83847bb7073f622afadac4168b078c5f979d06c70d649ec8a4dde.json similarity index 68% rename from crates/zopp-store-postgres/.sqlx/query-5e235e795e8ae96f88596fdf40839d73534e2aa607cdf78638bc797f8aa9f6bd.json rename to crates/zopp-store-postgres/.sqlx/query-08a20072e1d83847bb7073f622afadac4168b078c5f979d06c70d649ec8a4dde.json index 5c71423e..4c1bc56b 100644 --- a/crates/zopp-store-postgres/.sqlx/query-5e235e795e8ae96f88596fdf40839d73534e2aa607cdf78638bc797f8aa9f6bd.json +++ b/crates/zopp-store-postgres/.sqlx/query-08a20072e1d83847bb7073f622afadac4168b078c5f979d06c70d649ec8a4dde.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, email, created_at, updated_at FROM users WHERE id = $1", + "query": "SELECT id, email, verified, created_at, updated_at FROM users WHERE id = $1", "describe": { "columns": [ { @@ -15,11 +15,16 @@ }, { "ordinal": 2, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 3, + "ordinal": 4, "name": "updated_at", "type_info": "Timestamptz" } @@ -33,8 +38,9 @@ false, false, false, + false, false ] }, - "hash": "5e235e795e8ae96f88596fdf40839d73534e2aa607cdf78638bc797f8aa9f6bd" + "hash": "08a20072e1d83847bb7073f622afadac4168b078c5f979d06c70d649ec8a4dde" } diff --git a/crates/zopp-store-postgres/.sqlx/query-14e74c8e54d3554b2a3c18f95fa449b5e50898be6899df531afe8072775dc65c.json b/crates/zopp-store-postgres/.sqlx/query-14e74c8e54d3554b2a3c18f95fa449b5e50898be6899df531afe8072775dc65c.json deleted file mode 100644 index 07d7c7d6..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-14e74c8e54d3554b2a3c18f95fa449b5e50898be6899df531afe8072775dc65c.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users(id, email) VALUES($1, $2)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "14e74c8e54d3554b2a3c18f95fa449b5e50898be6899df531afe8072775dc65c" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-1e81dfa973d6118a99e4595f6d722360cc9d11137e4ee95d25119a2ea36c9ba0.json b/crates/zopp-store-postgres/.sqlx/query-1e81dfa973d6118a99e4595f6d722360cc9d11137e4ee95d25119a2ea36c9ba0.json new file mode 100644 index 00000000..95afa24e --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-1e81dfa973d6118a99e4595f6d722360cc9d11137e4ee95d25119a2ea36c9ba0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET verified = TRUE WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1e81dfa973d6118a99e4595f6d722360cc9d11137e4ee95d25119a2ea36c9ba0" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json b/crates/zopp-store-postgres/.sqlx/query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json new file mode 100644 index 00000000..eda96033 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1 AND revoked = FALSE) as \"exists!: bool\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!: bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-4e82b0d6f3a330f45b287c4be36e31d7067516df92e30e6f4899cbbb863f7433.json b/crates/zopp-store-postgres/.sqlx/query-4e82b0d6f3a330f45b287c4be36e31d7067516df92e30e6f4899cbbb863f7433.json new file mode 100644 index 00000000..679dc41f --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-4e82b0d6f3a330f45b287c4be36e31d7067516df92e30e6f4899cbbb863f7433.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users(id, email, verified) VALUES($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "4e82b0d6f3a330f45b287c4be36e31d7067516df92e30e6f4899cbbb863f7433" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-544f360cf9e89ba532bd4891644edc33460362192a43ba21cac576f25c7db1f4.json b/crates/zopp-store-postgres/.sqlx/query-544f360cf9e89ba532bd4891644edc33460362192a43ba21cac576f25c7db1f4.json new file mode 100644 index 00000000..01ebec0b --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-544f360cf9e89ba532bd4891644edc33460362192a43ba21cac576f25c7db1f4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE email_verifications \n SET attempts = attempts + 1 \n WHERE id = $1 \n RETURNING attempts", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "attempts", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "544f360cf9e89ba532bd4891644edc33460362192a43ba21cac576f25c7db1f4" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-738ec493069918d00302339f499a8963ea4f604e94e0d873c55848dd276a4243.json b/crates/zopp-store-postgres/.sqlx/query-738ec493069918d00302339f499a8963ea4f604e94e0d873c55848dd276a4243.json new file mode 100644 index 00000000..56eb489e --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-738ec493069918d00302339f499a8963ea4f604e94e0d873c55848dd276a4243.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM email_verifications WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "738ec493069918d00302339f499a8963ea4f604e94e0d873c55848dd276a4243" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-1a285dd71495b22c02108a7e4771d2e3d72018117646ed60f1d71a4aee4286ca.json b/crates/zopp-store-postgres/.sqlx/query-8252d71d0b77bbeb8d62158b5554afabfc4d907e77b19cb54745aa46512893a2.json similarity index 68% rename from crates/zopp-store-postgres/.sqlx/query-1a285dd71495b22c02108a7e4771d2e3d72018117646ed60f1d71a4aee4286ca.json rename to crates/zopp-store-postgres/.sqlx/query-8252d71d0b77bbeb8d62158b5554afabfc4d907e77b19cb54745aa46512893a2.json index 01f02589..11bf59d6 100644 --- a/crates/zopp-store-postgres/.sqlx/query-1a285dd71495b22c02108a7e4771d2e3d72018117646ed60f1d71a4aee4286ca.json +++ b/crates/zopp-store-postgres/.sqlx/query-8252d71d0b77bbeb8d62158b5554afabfc4d907e77b19cb54745aa46512893a2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, email, created_at, updated_at FROM users WHERE email = $1", + "query": "SELECT id, email, verified, created_at, updated_at FROM users WHERE email = $1", "describe": { "columns": [ { @@ -15,11 +15,16 @@ }, { "ordinal": 2, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 3, + "ordinal": 4, "name": "updated_at", "type_info": "Timestamptz" } @@ -33,8 +38,9 @@ false, false, false, + false, false ] }, - "hash": "1a285dd71495b22c02108a7e4771d2e3d72018117646ed60f1d71a4aee4286ca" + "hash": "8252d71d0b77bbeb8d62158b5554afabfc4d907e77b19cb54745aa46512893a2" } diff --git a/crates/zopp-store-postgres/.sqlx/query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json b/crates/zopp-store-postgres/.sqlx/query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json new file mode 100644 index 00000000..cdaf09a2 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE AND revoked = FALSE", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-65d598cc37def0ad646786055025e60c57ea9054b9e3b1e386b27fe9d1809026.json b/crates/zopp-store-postgres/.sqlx/query-9ecf9bec1f1555e9831df527cc90547f1621dc5344a12ab5cf0a7fc16f9c1712.json similarity index 71% rename from crates/zopp-store-postgres/.sqlx/query-65d598cc37def0ad646786055025e60c57ea9054b9e3b1e386b27fe9d1809026.json rename to crates/zopp-store-postgres/.sqlx/query-9ecf9bec1f1555e9831df527cc90547f1621dc5344a12ab5cf0a7fc16f9c1712.json index eadfeea7..1ced107f 100644 --- a/crates/zopp-store-postgres/.sqlx/query-65d598cc37def0ad646786055025e60c57ea9054b9e3b1e386b27fe9d1809026.json +++ b/crates/zopp-store-postgres/.sqlx/query-9ecf9bec1f1555e9831df527cc90547f1621dc5344a12ab5cf0a7fc16f9c1712.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, kek_encrypted, kek_nonce\n FROM invites\n WHERE revoked = FALSE AND (\n ($1::UUID IS NOT NULL AND created_by_user_id = $1) OR\n ($1::UUID IS NULL AND created_by_user_id IS NULL)\n )", + "query": "SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce\n FROM invites\n WHERE revoked = FALSE AND (\n ($1::UUID IS NOT NULL AND created_by_user_id = $1) OR\n ($1::UUID IS NULL AND created_by_user_id IS NULL)\n )", "describe": { "columns": [ { @@ -40,11 +40,16 @@ }, { "ordinal": 7, + "name": "consumed", + "type_info": "Bool" + }, + { + "ordinal": 8, "name": "kek_encrypted", "type_info": "Bytea" }, { - "ordinal": 8, + "ordinal": 9, "name": "kek_nonce", "type_info": "Bytea" } @@ -62,9 +67,10 @@ false, true, false, + false, true, true ] }, - "hash": "65d598cc37def0ad646786055025e60c57ea9054b9e3b1e386b27fe9d1809026" + "hash": "9ecf9bec1f1555e9831df527cc90547f1621dc5344a12ab5cf0a7fc16f9c1712" } diff --git a/crates/zopp-store-postgres/.sqlx/query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json b/crates/zopp-store-postgres/.sqlx/query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json new file mode 100644 index 00000000..7be7670a --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email, code_hash, invite_token, attempts, created_at, expires_at\n FROM email_verifications\n WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "code_hash", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "invite_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json b/crates/zopp-store-postgres/.sqlx/query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json new file mode 100644 index 00000000..ff936973 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO email_verifications(id, email, code_hash, invite_token, expires_at)\n VALUES($1, $2, $3, $4, $5)\n ON CONFLICT (email) DO UPDATE SET\n id = EXCLUDED.id,\n code_hash = EXCLUDED.code_hash,\n invite_token = EXCLUDED.invite_token,\n expires_at = EXCLUDED.expires_at,\n attempts = 0,\n created_at = NOW()\n RETURNING id, email, code_hash, invite_token, attempts, created_at, expires_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "code_hash", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "invite_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-7dbcdfe7b2f7c200d8060fdc78ec61569d79c9b153f6a3f3c64a473c3bbe859e.json b/crates/zopp-store-postgres/.sqlx/query-d00910b970071aaefc4c46a26d2c2f6ea088e29f2c33a9ee8eba6dc80cd28fbc.json similarity index 80% rename from crates/zopp-store-postgres/.sqlx/query-7dbcdfe7b2f7c200d8060fdc78ec61569d79c9b153f6a3f3c64a473c3bbe859e.json rename to crates/zopp-store-postgres/.sqlx/query-d00910b970071aaefc4c46a26d2c2f6ea088e29f2c33a9ee8eba6dc80cd28fbc.json index 59d86fa9..96448c18 100644 --- a/crates/zopp-store-postgres/.sqlx/query-7dbcdfe7b2f7c200d8060fdc78ec61569d79c9b153f6a3f3c64a473c3bbe859e.json +++ b/crates/zopp-store-postgres/.sqlx/query-d00910b970071aaefc4c46a26d2c2f6ea088e29f2c33a9ee8eba6dc80cd28fbc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, kek_encrypted, kek_nonce\n FROM invites WHERE token = $1", + "query": "SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce\n FROM invites WHERE token = $1", "describe": { "columns": [ { @@ -40,11 +40,16 @@ }, { "ordinal": 7, + "name": "consumed", + "type_info": "Bool" + }, + { + "ordinal": 8, "name": "kek_encrypted", "type_info": "Bytea" }, { - "ordinal": 8, + "ordinal": 9, "name": "kek_nonce", "type_info": "Bytea" } @@ -62,9 +67,10 @@ false, true, false, + false, true, true ] }, - "hash": "7dbcdfe7b2f7c200d8060fdc78ec61569d79c9b153f6a3f3c64a473c3bbe859e" + "hash": "d00910b970071aaefc4c46a26d2c2f6ea088e29f2c33a9ee8eba6dc80cd28fbc" } diff --git a/crates/zopp-store-postgres/.sqlx/query-ded0b0364a6eb190ecfc3734bd0428b515c7b26311fcc23cfab18bf06160cdaf.json b/crates/zopp-store-postgres/.sqlx/query-ded0b0364a6eb190ecfc3734bd0428b515c7b26311fcc23cfab18bf06160cdaf.json new file mode 100644 index 00000000..ad8c7bff --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-ded0b0364a6eb190ecfc3734bd0428b515c7b26311fcc23cfab18bf06160cdaf.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM email_verifications WHERE expires_at < $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "ded0b0364a6eb190ecfc3734bd0428b515c7b26311fcc23cfab18bf06160cdaf" +} diff --git a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql new file mode 100644 index 00000000..5dd507a1 --- /dev/null +++ b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql @@ -0,0 +1,27 @@ +-- Email verification with pending join data +-- Verification is per-user (email), not per-principal +-- Principal is created only after successful verification + +-- Add verified flag to users table +-- Step 1: Add column with DEFAULT TRUE to grandfather existing users as verified +ALTER TABLE users ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE; +-- Step 2: Change default to FALSE for new users (verification flow creates unverified users) +ALTER TABLE users ALTER COLUMN verified SET DEFAULT FALSE; + +-- Add consumed flag to invites table (tracks whether invite has been used) +ALTER TABLE invites ADD COLUMN consumed BOOLEAN NOT NULL DEFAULT FALSE; + +-- Email verification / pending join table +-- One record per email, upserted on each join attempt +CREATE TABLE IF NOT EXISTS email_verifications ( + id UUID PRIMARY KEY NOT NULL, + email TEXT NOT NULL UNIQUE, -- Email being verified (lowercased, unique) + code_hash TEXT NOT NULL, -- Argon2id hash of verification code (zero-knowledge) + invite_token TEXT NOT NULL, -- Invite token to consume on success + attempts INTEGER NOT NULL DEFAULT 0, -- Failed verification attempts + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL -- 15 minutes from created_at +); + +-- Index for cleanup of expired verifications +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); diff --git a/crates/zopp-store-postgres/src/lib.rs b/crates/zopp-store-postgres/src/lib.rs index 51b01c39..b9f0e998 100644 --- a/crates/zopp-store-postgres/src/lib.rs +++ b/crates/zopp-store-postgres/src/lib.rs @@ -6,8 +6,9 @@ use zopp_audit::{ AuditAction, AuditEvent, AuditLog, AuditLogError, AuditLogFilter, AuditLogId, AuditResult, }; use zopp_storage::{ - AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams, - CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, + AddWorkspacePrincipalParams, CreateEmailVerificationParams, CreateEnvParams, + CreateInviteParams, CreatePrincipalExportParams, CreatePrincipalParams, CreateProjectParams, + CreateUserParams, CreateWorkspaceParams, EmailVerification, EmailVerificationId, EnvName, Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal, PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role, SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission, @@ -63,10 +64,14 @@ impl Store for PostgresStore { existing.id } else { let user_id = Uuid::now_v7(); + // If principal is being created, user is immediately verified (non-verification flow) + // If no principal, user is not verified yet (verification flow) + let verified = params.principal.is_some(); sqlx::query!( - "INSERT INTO users(id, email) VALUES($1, $2)", + "INSERT INTO users(id, email, verified) VALUES($1, $2, $3)", user_id, - params.email + params.email, + verified ) .execute(&mut *tx) .await @@ -119,11 +124,14 @@ impl Store for PostgresStore { Ok((UserId(actual_user_id), principal_id)) } else { let user_id = Uuid::now_v7(); + // No principal = verification flow, so user is not verified yet + let verified = false; sqlx::query!( - "INSERT INTO users(id, email) VALUES($1, $2)", + "INSERT INTO users(id, email, verified) VALUES($1, $2, $3)", user_id, - params.email + params.email, + verified ) .execute(&self.pool) .await @@ -142,7 +150,7 @@ impl Store for PostgresStore { async fn get_user_by_email(&self, email: &str) -> Result { let row = sqlx::query!( - r#"SELECT id, email, created_at, updated_at FROM users WHERE email = $1"#, + r#"SELECT id, email, verified, created_at, updated_at FROM users WHERE email = $1"#, email ) .fetch_optional(&self.pool) @@ -153,6 +161,7 @@ impl Store for PostgresStore { Ok(User { id: UserId(row.id), email: row.email, + verified: row.verified, created_at: row.created_at, updated_at: row.updated_at, }) @@ -160,7 +169,7 @@ impl Store for PostgresStore { async fn get_user_by_id(&self, user_id: &UserId) -> Result { let row = sqlx::query!( - r#"SELECT id, email, created_at, updated_at FROM users WHERE id = $1"#, + r#"SELECT id, email, verified, created_at, updated_at FROM users WHERE id = $1"#, user_id.0 ) .fetch_optional(&self.pool) @@ -171,6 +180,7 @@ impl Store for PostgresStore { Ok(User { id: UserId(row.id), email: row.email, + verified: row.verified, created_at: row.created_at, updated_at: row.updated_at, }) @@ -315,12 +325,13 @@ impl Store for PostgresStore { updated_at: row.updated_at, expires_at: params.expires_at, created_by_user_id: params.created_by_user_id.clone(), + consumed: false, }) } async fn get_invite_by_token(&self, token: &str) -> Result { let row = sqlx::query!( - r#"SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, kek_encrypted, kek_nonce + r#"SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce FROM invites WHERE token = $1"#, token ) @@ -356,6 +367,7 @@ impl Store for PostgresStore { updated_at: row.updated_at, expires_at: row.expires_at, created_by_user_id: row.created_by_user_id.map(UserId), + consumed: row.consumed, }) } @@ -363,7 +375,7 @@ impl Store for PostgresStore { let user_id_opt = user_id.map(|id| id.0); let rows = sqlx::query!( - r#"SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, kek_encrypted, kek_nonce + r#"SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce FROM invites WHERE revoked = FALSE AND ( ($1::UUID IS NOT NULL AND created_by_user_id = $1) OR @@ -400,6 +412,7 @@ impl Store for PostgresStore { updated_at: row.updated_at, expires_at: row.expires_at, created_by_user_id: row.created_by_user_id.map(UserId), + consumed: row.consumed, }); } Ok(invites) @@ -421,6 +434,38 @@ impl Store for PostgresStore { } } + async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { + // Atomically consume invite only if not already consumed and not revoked + // This prevents concurrent requests from both succeeding + let result = sqlx::query!( + "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE AND revoked = FALSE", + token + ) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + // Either token doesn't exist, invite was already consumed, or it's revoked + // Check which case it is for a more specific error + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1 AND revoked = FALSE) as \"exists!: bool\"", + token + ) + .fetch_one(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if exists { + Err(StoreError::AlreadyExists) // Invite was already consumed + } else { + Err(StoreError::NotFound) // Token doesn't exist or is revoked + } + } else { + Ok(()) + } + } + // ───────────────────────────── Principal Exports ────────────────────────── async fn create_principal_export( @@ -555,6 +600,128 @@ impl Store for PostgresStore { } } + // ───────────────────────────── Email Verification ───────────────────────────── + + async fn create_email_verification( + &self, + params: &CreateEmailVerificationParams, + ) -> Result { + let id = Uuid::now_v7(); + let email = params.email.to_lowercase(); + + // Upsert: email is unique, so this replaces any existing verification for this email + let row = sqlx::query!( + r#"INSERT INTO email_verifications(id, email, code_hash, invite_token, expires_at) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (email) DO UPDATE SET + id = EXCLUDED.id, + code_hash = EXCLUDED.code_hash, + invite_token = EXCLUDED.invite_token, + expires_at = EXCLUDED.expires_at, + attempts = 0, + created_at = NOW() + RETURNING id, email, code_hash, invite_token, attempts, created_at, expires_at"#, + id, + email, + params.code_hash, + params.invite_token, + params.expires_at + ) + .fetch_one(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + Ok(EmailVerification { + id: EmailVerificationId(row.id), + email: row.email, + code_hash: row.code_hash, + invite_token: row.invite_token, + attempts: row.attempts, + created_at: row.created_at, + expires_at: row.expires_at, + }) + } + + async fn get_email_verification(&self, email: &str) -> Result { + let email_lower = email.to_lowercase(); + // Email is unique, so no need for ORDER BY/LIMIT + let row = sqlx::query!( + r#"SELECT id, email, code_hash, invite_token, attempts, created_at, expires_at + FROM email_verifications + WHERE email = $1"#, + email_lower + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))? + .ok_or(StoreError::NotFound)?; + + Ok(EmailVerification { + id: EmailVerificationId(row.id), + email: row.email, + code_hash: row.code_hash, + invite_token: row.invite_token, + attempts: row.attempts, + created_at: row.created_at, + expires_at: row.expires_at, + }) + } + + async fn increment_email_verification_attempts( + &self, + id: &EmailVerificationId, + ) -> Result { + let row = sqlx::query!( + r#"UPDATE email_verifications + SET attempts = attempts + 1 + WHERE id = $1 + RETURNING attempts"#, + id.0 + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))? + .ok_or(StoreError::NotFound)?; + + Ok(row.attempts) + } + + async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> { + let result = sqlx::query!("DELETE FROM email_verifications WHERE id = $1", id.0) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(StoreError::NotFound) + } else { + Ok(()) + } + } + + async fn cleanup_expired_email_verifications(&self) -> Result { + let now = Utc::now(); + let result = sqlx::query!("DELETE FROM email_verifications WHERE expires_at < $1", now) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + Ok(result.rows_affected()) + } + + async fn mark_user_verified(&self, user_id: &UserId) -> Result<(), StoreError> { + let result = sqlx::query!("UPDATE users SET verified = TRUE WHERE id = $1", user_id.0) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(StoreError::NotFound) + } else { + Ok(()) + } + } + // ───────────────────────────── Workspaces ───────────────────────────── async fn create_workspace( diff --git a/crates/zopp-store-sqlite/.sqlx/query-045156a45d66fd0a2db3b483fea20de32c1a7c4d0106603004fde0098b566015.json b/crates/zopp-store-sqlite/.sqlx/query-045156a45d66fd0a2db3b483fea20de32c1a7c4d0106603004fde0098b566015.json new file mode 100644 index 00000000..b0a0e2dc --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-045156a45d66fd0a2db3b483fea20de32c1a7c4d0106603004fde0098b566015.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM email_verifications WHERE expires_at < ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "045156a45d66fd0a2db3b483fea20de32c1a7c4d0106603004fde0098b566015" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-27acc49ba9407e635b7ce84853e9e76ef46b9833cbe08268bce7c802128d1a3f.json b/crates/zopp-store-sqlite/.sqlx/query-27acc49ba9407e635b7ce84853e9e76ef46b9833cbe08268bce7c802128d1a3f.json new file mode 100644 index 00000000..9270bb8c --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-27acc49ba9407e635b7ce84853e9e76ef46b9833cbe08268bce7c802128d1a3f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE users SET verified = 1 WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "27acc49ba9407e635b7ce84853e9e76ef46b9833cbe08268bce7c802128d1a3f" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-2f9136a88959b45e3095b5ee200c11f8fdf82a19ef1069b7e4025c2d88a72480.json b/crates/zopp-store-sqlite/.sqlx/query-2f9136a88959b45e3095b5ee200c11f8fdf82a19ef1069b7e4025c2d88a72480.json new file mode 100644 index 00000000..f3f62007 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-2f9136a88959b45e3095b5ee200c11f8fdf82a19ef1069b7e4025c2d88a72480.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users(id, email, verified) VALUES(?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "2f9136a88959b45e3095b5ee200c11f8fdf82a19ef1069b7e4025c2d88a72480" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-354e15030102ae3406948f967123ad9883d7f21faae9c7760842e8da25412da9.json b/crates/zopp-store-sqlite/.sqlx/query-354e15030102ae3406948f967123ad9883d7f21faae9c7760842e8da25412da9.json new file mode 100644 index 00000000..f22c8317 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-354e15030102ae3406948f967123ad9883d7f21faae9c7760842e8da25412da9.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT attempts FROM email_verifications WHERE id = ?", + "describe": { + "columns": [ + { + "name": "attempts", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "354e15030102ae3406948f967123ad9883d7f21faae9c7760842e8da25412da9" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.json b/crates/zopp-store-sqlite/.sqlx/query-43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d.json similarity index 60% rename from crates/zopp-store-sqlite/.sqlx/query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.json rename to crates/zopp-store-sqlite/.sqlx/query-43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d.json index bec94287..a963785f 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.json +++ b/crates/zopp-store-sqlite/.sqlx/query-43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM users WHERE id = ?", + "query": "SELECT id, email, verified,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM users WHERE email = ?", "describe": { "columns": [ { @@ -14,13 +14,18 @@ "type_info": "Text" }, { - "name": "created_at: DateTime", + "name": "verified", "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, "type_info": "Text" }, { "name": "updated_at: DateTime", - "ordinal": 3, + "ordinal": 4, "type_info": "Text" } ], @@ -31,8 +36,9 @@ false, false, false, + false, false ] }, - "hash": "02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43" + "hash": "43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.json b/crates/zopp-store-sqlite/.sqlx/query-4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1.json similarity index 60% rename from crates/zopp-store-sqlite/.sqlx/query-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.json rename to crates/zopp-store-sqlite/.sqlx/query-4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1.json index 103b13d5..38d7e7c7 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.json +++ b/crates/zopp-store-sqlite/.sqlx/query-4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM users WHERE email = ?", + "query": "SELECT id, email, verified,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM users WHERE id = ?", "describe": { "columns": [ { @@ -14,13 +14,18 @@ "type_info": "Text" }, { - "name": "created_at: DateTime", + "name": "verified", "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, "type_info": "Text" }, { "name": "updated_at: DateTime", - "ordinal": 3, + "ordinal": 4, "type_info": "Text" } ], @@ -31,8 +36,9 @@ false, false, false, + false, false ] }, - "hash": "b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed" + "hash": "4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-50304f1fda474cb4c3554604f5d32c905a4fbf4fb06296e2c05c91248b7936c5.json b/crates/zopp-store-sqlite/.sqlx/query-50304f1fda474cb4c3554604f5d32c905a4fbf4fb06296e2c05c91248b7936c5.json new file mode 100644 index 00000000..0458cbdd --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-50304f1fda474cb4c3554604f5d32c905a4fbf4fb06296e2c05c91248b7936c5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM email_verifications WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "50304f1fda474cb4c3554604f5d32c905a4fbf4fb06296e2c05c91248b7936c5" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-bbc63cffac81e98d1f1933634b23cffc0b89062d4e6cdc8388285278dd63de50.json b/crates/zopp-store-sqlite/.sqlx/query-6d4f2da0f6cfe7be582f4c7b631a977bedc1358d15d232d1eb4b680b9021a3d4.json similarity index 83% rename from crates/zopp-store-sqlite/.sqlx/query-bbc63cffac81e98d1f1933634b23cffc0b89062d4e6cdc8388285278dd63de50.json rename to crates/zopp-store-sqlite/.sqlx/query-6d4f2da0f6cfe7be582f4c7b631a977bedc1358d15d232d1eb4b680b9021a3d4.json index 33e18586..3af4dfde 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-bbc63cffac81e98d1f1933634b23cffc0b89062d4e6cdc8388285278dd63de50.json +++ b/crates/zopp-store-sqlite/.sqlx/query-6d4f2da0f6cfe7be582f4c7b631a977bedc1358d15d232d1eb4b680b9021a3d4.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, token,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n expires_at as \"expires_at: DateTime\",\n created_by_user_id, revoked, kek_encrypted, kek_nonce\n FROM invites WHERE token = ?", + "query": "SELECT id, token,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n expires_at as \"expires_at: DateTime\",\n created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce\n FROM invites WHERE token = ?", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Integer" }, { - "name": "kek_encrypted", + "name": "consumed", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "kek_encrypted", + "ordinal": 8, "type_info": "Blob" }, { "name": "kek_nonce", - "ordinal": 8, + "ordinal": 9, "type_info": "Blob" } ], @@ -60,9 +65,10 @@ false, true, false, + false, true, true ] }, - "hash": "bbc63cffac81e98d1f1933634b23cffc0b89062d4e6cdc8388285278dd63de50" + "hash": "6d4f2da0f6cfe7be582f4c7b631a977bedc1358d15d232d1eb4b680b9021a3d4" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-8d7e649f9dabcf1fd8e83cf95e8ed81d8f80b43ce8f82f00dd713f8dc6b42c5e.json b/crates/zopp-store-sqlite/.sqlx/query-8d7e649f9dabcf1fd8e83cf95e8ed81d8f80b43ce8f82f00dd713f8dc6b42c5e.json new file mode 100644 index 00000000..ed953935 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-8d7e649f9dabcf1fd8e83cf95e8ed81d8f80b43ce8f82f00dd713f8dc6b42c5e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8d7e649f9dabcf1fd8e83cf95e8ed81d8f80b43ce8f82f00dd713f8dc6b42c5e" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json b/crates/zopp-store-sqlite/.sqlx/query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json new file mode 100644 index 00000000..71613e48 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO email_verifications(id, email, code_hash, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json b/crates/zopp-store-sqlite/.sqlx/query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json new file mode 100644 index 00000000..a71e421e --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT token FROM invites WHERE token = ? AND revoked = 0", + "describe": { + "columns": [ + { + "name": "token", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json b/crates/zopp-store-sqlite/.sqlx/query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json new file mode 100644 index 00000000..9aeab57f --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, code_hash, invite_token, attempts,\n created_at as \"created_at: DateTime\",\n expires_at as \"expires_at: DateTime\"\n FROM email_verifications\n WHERE email = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "code_hash", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "invite_token", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "attempts", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "expires_at: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-270ae06cdf65b7c1e0e9edf93006727ccb30ece860a735e8b03f7929e758355c.json b/crates/zopp-store-sqlite/.sqlx/query-bef5faa625e45467d36e87265e7859f23c29faccc6a6bed608384587241e6ccf.json similarity index 76% rename from crates/zopp-store-sqlite/.sqlx/query-270ae06cdf65b7c1e0e9edf93006727ccb30ece860a735e8b03f7929e758355c.json rename to crates/zopp-store-sqlite/.sqlx/query-bef5faa625e45467d36e87265e7859f23c29faccc6a6bed608384587241e6ccf.json index 42181c01..1b95c782 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-270ae06cdf65b7c1e0e9edf93006727ccb30ece860a735e8b03f7929e758355c.json +++ b/crates/zopp-store-sqlite/.sqlx/query-bef5faa625e45467d36e87265e7859f23c29faccc6a6bed608384587241e6ccf.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, token,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n expires_at as \"expires_at: DateTime\",\n created_by_user_id, revoked, kek_encrypted, kek_nonce\n FROM invites\n WHERE revoked = 0 AND (\n (? IS NOT NULL AND created_by_user_id = ?) OR\n (? IS NULL AND created_by_user_id IS NULL)\n )", + "query": "SELECT id, token,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n expires_at as \"expires_at: DateTime\",\n created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce\n FROM invites\n WHERE revoked = 0 AND (\n (? IS NOT NULL AND created_by_user_id = ?) OR\n (? IS NULL AND created_by_user_id IS NULL)\n )", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Integer" }, { - "name": "kek_encrypted", + "name": "consumed", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "kek_encrypted", + "ordinal": 8, "type_info": "Blob" }, { "name": "kek_nonce", - "ordinal": 8, + "ordinal": 9, "type_info": "Blob" } ], @@ -60,9 +65,10 @@ false, true, false, + false, true, true ] }, - "hash": "270ae06cdf65b7c1e0e9edf93006727ccb30ece860a735e8b03f7929e758355c" + "hash": "bef5faa625e45467d36e87265e7859f23c29faccc6a6bed608384587241e6ccf" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-d27d6d7e46a8019339d526322ec8341cf1fadb2698e55375fa0eb9f085e7f591.json b/crates/zopp-store-sqlite/.sqlx/query-d27d6d7e46a8019339d526322ec8341cf1fadb2698e55375fa0eb9f085e7f591.json deleted file mode 100644 index 157eef6d..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-d27d6d7e46a8019339d526322ec8341cf1fadb2698e55375fa0eb9f085e7f591.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO users(id, email) VALUES(?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "d27d6d7e46a8019339d526322ec8341cf1fadb2698e55375fa0eb9f085e7f591" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json b/crates/zopp-store-sqlite/.sqlx/query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json new file mode 100644 index 00000000..d12802b5 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0 AND revoked = 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc" +} diff --git a/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql new file mode 100644 index 00000000..44483bd6 --- /dev/null +++ b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql @@ -0,0 +1,26 @@ +-- Email verification with pending join data +-- Verification is per-user (email), not per-principal +-- Principal is created only after successful verification + +-- Add verified flag to users table +-- Existing users are grandfathered in as verified (1=TRUE) since they joined before verification was required +-- New users joining with verification enabled will have verified=0 until email is verified +ALTER TABLE users ADD COLUMN verified INTEGER NOT NULL DEFAULT 1; + +-- Add consumed flag to invites table (tracks whether invite has been used) +ALTER TABLE invites ADD COLUMN consumed INTEGER NOT NULL DEFAULT 0; + +-- Email verification / pending join table +-- One record per email, upserted on each join attempt +CREATE TABLE IF NOT EXISTS email_verifications ( + id TEXT PRIMARY KEY NOT NULL, -- UUID string + email TEXT NOT NULL UNIQUE, -- Email being verified (lowercased, unique) + code_hash TEXT NOT NULL, -- Argon2id hash of verification code (zero-knowledge) + invite_token TEXT NOT NULL, -- Invite token to consume on success + attempts INTEGER NOT NULL DEFAULT 0, -- Failed verification attempts + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + expires_at TEXT NOT NULL -- 15 minutes from created_at +); + +-- Index for cleanup of expired verifications +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); diff --git a/crates/zopp-store-sqlite/src/lib.rs b/crates/zopp-store-sqlite/src/lib.rs index f99a562a..a5ba3ca6 100644 --- a/crates/zopp-store-sqlite/src/lib.rs +++ b/crates/zopp-store-sqlite/src/lib.rs @@ -6,8 +6,9 @@ use zopp_audit::{ AuditAction, AuditEvent, AuditLog, AuditLogError, AuditLogFilter, AuditLogId, AuditResult, }; use zopp_storage::{ - AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams, - CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, + AddWorkspacePrincipalParams, CreateEmailVerificationParams, CreateEnvParams, + CreateInviteParams, CreatePrincipalExportParams, CreatePrincipalParams, CreateProjectParams, + CreateUserParams, CreateWorkspaceParams, EmailVerification, EmailVerificationId, EnvName, Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal, PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role, SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission, @@ -89,10 +90,14 @@ impl Store for SqliteStore { // Create new user let user_id = Uuid::now_v7(); let user_id_str = user_id.to_string(); + // If principal is being created, user is immediately verified (non-verification flow) + // If no principal, user is not verified yet (verification flow) + let verified: i32 = if params.principal.is_some() { 1 } else { 0 }; sqlx::query!( - "INSERT INTO users(id, email) VALUES(?, ?)", + "INSERT INTO users(id, email, verified) VALUES(?, ?, ?)", user_id_str, - params.email + params.email, + verified ) .execute(&mut *tx) .await @@ -122,7 +127,14 @@ impl Store for SqliteStore { ) .execute(&mut *tx) .await - .map_err(|e| StoreError::Backend(e.to_string()))?; + .map_err(|e| { + let s = e.to_string(); + if s.contains("UNIQUE") { + StoreError::AlreadyExists + } else { + StoreError::Backend(s) + } + })?; Some(PrincipalId(principal_id)) } else { @@ -153,14 +165,16 @@ impl Store for SqliteStore { Ok((UserId(actual_user_id), principal_id)) } else { - // Simple case: just create user + // Simple case: just create user (no principal = verification flow, not verified yet) let user_id = Uuid::now_v7(); let user_id_str = user_id.to_string(); + let verified: i32 = 0; // Not verified in verification flow sqlx::query!( - "INSERT INTO users(id, email) VALUES(?, ?)", + "INSERT INTO users(id, email, verified) VALUES(?, ?, ?)", user_id_str, - params.email + params.email, + verified ) .execute(&self.pool) .await @@ -179,7 +193,7 @@ impl Store for SqliteStore { async fn get_user_by_email(&self, email: &str) -> Result { let row = sqlx::query!( - r#"SELECT id, email, + r#"SELECT id, email, verified, created_at as "created_at: DateTime", updated_at as "updated_at: DateTime" FROM users WHERE email = ?"#, @@ -197,6 +211,7 @@ impl Store for SqliteStore { Ok(User { id: UserId(id), email: row.email, + verified: row.verified != 0, created_at: row.created_at, updated_at: row.updated_at, }) @@ -207,7 +222,7 @@ impl Store for SqliteStore { async fn get_user_by_id(&self, user_id: &UserId) -> Result { let user_id_str = user_id.0.to_string(); let row = sqlx::query!( - r#"SELECT id, email, + r#"SELECT id, email, verified, created_at as "created_at: DateTime", updated_at as "updated_at: DateTime" FROM users WHERE id = ?"#, @@ -225,6 +240,7 @@ impl Store for SqliteStore { Ok(User { id: UserId(id), email: row.email, + verified: row.verified != 0, created_at: row.created_at, updated_at: row.updated_at, }) @@ -415,6 +431,7 @@ impl Store for SqliteStore { updated_at: row.updated_at, expires_at: params.expires_at, created_by_user_id: params.created_by_user_id.clone(), + consumed: false, }) } @@ -424,7 +441,7 @@ impl Store for SqliteStore { created_at as "created_at: DateTime", updated_at as "updated_at: DateTime", expires_at as "expires_at: DateTime", - created_by_user_id, revoked, kek_encrypted, kek_nonce + created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce FROM invites WHERE token = ?"#, token ) @@ -475,6 +492,7 @@ impl Store for SqliteStore { updated_at: row.updated_at, expires_at: row.expires_at, created_by_user_id, + consumed: row.consumed != 0, }) } } @@ -489,7 +507,7 @@ impl Store for SqliteStore { created_at as "created_at: DateTime", updated_at as "updated_at: DateTime", expires_at as "expires_at: DateTime", - created_by_user_id, revoked, kek_encrypted, kek_nonce + created_by_user_id, revoked, consumed, kek_encrypted, kek_nonce FROM invites WHERE revoked = 0 AND ( (? IS NOT NULL AND created_by_user_id = ?) OR @@ -539,6 +557,7 @@ impl Store for SqliteStore { updated_at: row.updated_at, expires_at: row.expires_at, created_by_user_id, + consumed: row.consumed != 0, }); } Ok(invites) @@ -558,6 +577,38 @@ impl Store for SqliteStore { } } + async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { + // Atomically consume invite only if not already consumed and not revoked + // This prevents concurrent requests from both succeeding + let result = sqlx::query!( + "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0 AND revoked = 0", + token + ) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + // Either token doesn't exist, invite was already consumed, or it's revoked + // Check which case it is for a more specific error + let exists = sqlx::query!( + "SELECT token FROM invites WHERE token = ? AND revoked = 0", + token + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if exists.is_some() { + Err(StoreError::AlreadyExists) // Invite was already consumed + } else { + Err(StoreError::NotFound) // Token doesn't exist or is revoked + } + } else { + Ok(()) + } + } + // ───────────────────────────── Principal Exports ────────────────────────── async fn create_principal_export( @@ -711,6 +762,140 @@ impl Store for SqliteStore { } } + // ───────────────────────────── Email Verification ───────────────────────────── + + async fn create_email_verification( + &self, + params: &CreateEmailVerificationParams, + ) -> Result { + let id = Uuid::now_v7(); + let id_str = id.to_string(); + let email = params.email.to_lowercase(); + + // Upsert: email is unique, so this replaces any existing verification for this email + // Note: code_hash is already hashed by the caller (zero-knowledge) + sqlx::query!( + "INSERT OR REPLACE INTO email_verifications(id, email, code_hash, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", + id_str, + email, + params.code_hash, + params.invite_token, + params.expires_at + ) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + Ok(EmailVerification { + id: EmailVerificationId(id), + email, + code_hash: params.code_hash.clone(), + invite_token: params.invite_token.clone(), + attempts: 0, + created_at: Utc::now(), + expires_at: params.expires_at, + }) + } + + async fn get_email_verification(&self, email: &str) -> Result { + let email_lower = email.to_lowercase(); + // Email is unique, so no need for ORDER BY/LIMIT + let row = sqlx::query!( + r#"SELECT id, email, code_hash, invite_token, attempts, + created_at as "created_at: DateTime", + expires_at as "expires_at: DateTime" + FROM email_verifications + WHERE email = ?"#, + email_lower + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + match row { + None => Err(StoreError::NotFound), + Some(row) => { + let id = + Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?; + Ok(EmailVerification { + id: EmailVerificationId(id), + email: row.email, + code_hash: row.code_hash, + invite_token: row.invite_token, + attempts: row.attempts as i32, + created_at: row.created_at, + expires_at: row.expires_at, + }) + } + } + } + + async fn increment_email_verification_attempts( + &self, + id: &EmailVerificationId, + ) -> Result { + let id_str = id.0.to_string(); + sqlx::query!( + "UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?", + id_str + ) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + // Fetch the updated count + let row = sqlx::query!( + "SELECT attempts FROM email_verifications WHERE id = ?", + id_str + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + match row { + None => Err(StoreError::NotFound), + Some(row) => Ok(row.attempts as i32), + } + } + + async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> { + let id_str = id.0.to_string(); + let result = sqlx::query!("DELETE FROM email_verifications WHERE id = ?", id_str) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(StoreError::NotFound) + } else { + Ok(()) + } + } + + async fn cleanup_expired_email_verifications(&self) -> Result { + let now = Utc::now(); + let result = sqlx::query!("DELETE FROM email_verifications WHERE expires_at < ?", now) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + Ok(result.rows_affected()) + } + + async fn mark_user_verified(&self, user_id: &UserId) -> Result<(), StoreError> { + let user_id_str = user_id.0.to_string(); + let result = sqlx::query!("UPDATE users SET verified = 1 WHERE id = ?", user_id_str) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(StoreError::NotFound) + } else { + Ok(()) + } + } + // ───────────────────────────── Workspaces ───────────────────────────── async fn create_workspace( @@ -3270,7 +3455,7 @@ mod tests { .await .unwrap_err(); - matches!(err, StoreError::AlreadyExists); + assert!(matches!(err, StoreError::AlreadyExists)); } #[tokio::test] @@ -3337,7 +3522,7 @@ mod tests { // env2 must NOT be able to see env1's secret let err = s.get_secret(&env_id2, "TOKEN").await.unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -3416,7 +3601,7 @@ mod tests { }) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -3461,7 +3646,7 @@ mod tests { }) .await .unwrap_err(); - matches!(err, StoreError::AlreadyExists); + assert!(matches!(err, StoreError::AlreadyExists)); } #[tokio::test] @@ -3780,7 +3965,7 @@ mod tests { .get_workspace_permission(&ws, &principal_id) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -3878,7 +4063,7 @@ mod tests { .get_user_by_email("notfound@example.com") .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -3921,7 +4106,7 @@ mod tests { // Revoke invite s.revoke_invite(&invite.id).await.unwrap(); let err = s.get_invite_by_token("test-token").await.unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -3961,12 +4146,12 @@ mod tests { // Delete environment s.delete_environment(&env_id).await.unwrap(); let err = s.get_environment(&env_id).await.unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); // Delete project s.delete_project(&project_id).await.unwrap(); let err = s.get_project(&project_id).await.unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4056,7 +4241,7 @@ mod tests { .get_user_workspace_permission(&ws, &user_id) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4110,6 +4295,220 @@ mod tests { .get_workspace_principal(&ws, &principal_id) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); + } + + // ─────────────────────────── Email Verification Tests ─────────────────────────── + + #[tokio::test] + async fn create_and_get_email_verification() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + let params = zopp_storage::CreateEmailVerificationParams { + email: "Test@Example.com".to_string(), + code_hash: "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + .to_string(), // SHA256("123456") + invite_token: "test-token".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), + }; + + let verification = s.create_email_verification(¶ms).await.unwrap(); + assert_eq!(verification.email, "test@example.com"); // Should be lowercased + assert_eq!( + verification.code_hash, + "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + ); + assert_eq!(verification.invite_token, "test-token"); + assert_eq!(verification.attempts, 0); + + // Get by email (case insensitive) + let got = s.get_email_verification("TEST@EXAMPLE.COM").await.unwrap(); + assert_eq!(got.id, verification.id); + assert_eq!(got.email, "test@example.com"); + assert_eq!(got.invite_token, "test-token"); + } + + #[tokio::test] + async fn get_email_verification_not_found() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + let err = s + .get_email_verification("nonexistent@example.com") + .await + .unwrap_err(); + assert!(matches!(err, StoreError::NotFound)); + } + + #[tokio::test] + async fn increment_email_verification_attempts() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + let params = zopp_storage::CreateEmailVerificationParams { + email: "test@example.com".to_string(), + code_hash: "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + .to_string(), // SHA256("123456") + invite_token: "test-token".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), + }; + + let verification = s.create_email_verification(¶ms).await.unwrap(); + assert_eq!(verification.attempts, 0); + + // Increment attempts + let attempts = s + .increment_email_verification_attempts(&verification.id) + .await + .unwrap(); + assert_eq!(attempts, 1); + + let attempts = s + .increment_email_verification_attempts(&verification.id) + .await + .unwrap(); + assert_eq!(attempts, 2); + + // Verify by fetching + let got = s.get_email_verification("test@example.com").await.unwrap(); + assert_eq!(got.attempts, 2); + } + + #[tokio::test] + async fn delete_email_verification() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + let params = zopp_storage::CreateEmailVerificationParams { + email: "test@example.com".to_string(), + code_hash: "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + .to_string(), // SHA256("123456") + invite_token: "test-token".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), + }; + + let verification = s.create_email_verification(¶ms).await.unwrap(); + + // Delete + s.delete_email_verification(&verification.id).await.unwrap(); + + // Should not be found + let err = s + .get_email_verification("test@example.com") + .await + .unwrap_err(); + assert!(matches!(err, StoreError::NotFound)); + } + + #[tokio::test] + async fn delete_nonexistent_email_verification() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + let fake_id = zopp_storage::EmailVerificationId(uuid::Uuid::now_v7()); + let err = s.delete_email_verification(&fake_id).await.unwrap_err(); + assert!(matches!(err, StoreError::NotFound)); + } + + #[tokio::test] + async fn cleanup_expired_email_verifications() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + // Create an expired verification + let expired_params = zopp_storage::CreateEmailVerificationParams { + email: "expired@example.com".to_string(), + code_hash: "hash111111".to_string(), + invite_token: "expired-token".to_string(), + expires_at: chrono::Utc::now() - chrono::Duration::minutes(1), // Already expired + }; + s.create_email_verification(&expired_params).await.unwrap(); + + // Create a valid verification + let valid_params = zopp_storage::CreateEmailVerificationParams { + email: "valid@example.com".to_string(), + code_hash: "hash222222".to_string(), + invite_token: "valid-token".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), + }; + s.create_email_verification(&valid_params).await.unwrap(); + + // Cleanup should remove 1 expired record + let deleted = s.cleanup_expired_email_verifications().await.unwrap(); + assert_eq!(deleted, 1); + + // Expired one should be gone + let err = s + .get_email_verification("expired@example.com") + .await + .unwrap_err(); + assert!(matches!(err, StoreError::NotFound)); + + // Valid one should still exist + s.get_email_verification("valid@example.com").await.unwrap(); + } + + #[tokio::test] + async fn mark_user_verified() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + // Create user without principal (simulates email verification flow) + // Users created without principals are unverified until email is confirmed + let (user_id, _) = s + .create_user(&CreateUserParams { + email: "test@example.com".to_string(), + principal: None, // No principal - email verification required + workspace_ids: vec![], + }) + .await + .unwrap(); + + // Users without principals should be unverified + let user = s.get_user_by_id(&user_id).await.unwrap(); + assert!(!user.verified); + + // Mark as verified + s.mark_user_verified(&user_id).await.unwrap(); + + let user = s.get_user_by_id(&user_id).await.unwrap(); + assert!(user.verified); + + // Marking verified again should be idempotent + s.mark_user_verified(&user_id).await.unwrap(); + let user = s.get_user_by_id(&user_id).await.unwrap(); + assert!(user.verified); + } + + #[tokio::test] + async fn mark_nonexistent_user_verified() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + let fake_id = zopp_storage::UserId(uuid::Uuid::now_v7()); + let err = s.mark_user_verified(&fake_id).await.unwrap_err(); + assert!(matches!(err, StoreError::NotFound)); + } + + #[tokio::test] + async fn email_verification_upserts_on_same_email() { + let s = SqliteStore::open_in_memory().await.unwrap(); + + // Create first verification + let params1 = zopp_storage::CreateEmailVerificationParams { + email: "test@example.com".to_string(), + code_hash: "hash111111".to_string(), + invite_token: "token1".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), + }; + s.create_email_verification(¶ms1).await.unwrap(); + + // Create second verification for same email - should upsert + let params2 = zopp_storage::CreateEmailVerificationParams { + email: "test@example.com".to_string(), + code_hash: "hash222222".to_string(), + invite_token: "token2".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), + }; + let second = s.create_email_verification(¶ms2).await.unwrap(); + + // Should return the updated record with new code_hash + let got = s.get_email_verification("test@example.com").await.unwrap(); + assert_eq!(got.id, second.id); + assert_eq!(got.code_hash, "hash222222"); + assert_eq!(got.invite_token, "token2"); } } diff --git a/docker/docker-compose.test.yaml b/docker/docker-compose.test.yaml new file mode 100644 index 00000000..2e5f7348 --- /dev/null +++ b/docker/docker-compose.test.yaml @@ -0,0 +1,35 @@ +# Shared Docker Compose for E2E tests (CLI and Web) +# +# This provides a MailHog instance for capturing verification emails. +# Both Rust CLI tests and Playwright web tests can use this. +# +# Usage: +# # Start MailHog +# docker compose -f docker/docker-compose.test.yaml up -d +# +# # Run CLI E2E tests (uses MailHog on port 1025) +# MAILHOG_HOST=127.0.0.1 MAILHOG_PORT=1025 cargo test --package e2e-tests +# +# # Run web E2E tests +# cd apps/zopp-web && npm run test:e2e +# +# # View captured emails (optional) +# open http://localhost:8025 +# +# # Stop when done +# docker compose -f docker/docker-compose.test.yaml down + +services: + # MailHog - Mock SMTP server with web UI and API + # SMTP: port 1025 (servers send emails here) + # HTTP: port 8025 (web UI and API for retrieving emails) + mailhog: + image: mailhog/mailhog:latest + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI / API + environment: + MH_STORAGE: memory + MH_SMTP_BIND_ADDR: 0.0.0.0:1025 + MH_API_BIND_ADDR: 0.0.0.0:8025 + MH_UI_BIND_ADDR: 0.0.0.0:8025 diff --git a/docs/docs/guides/joining-a-team.md b/docs/docs/guides/joining-a-team.md index c93267fb..8dbfc017 100644 --- a/docs/docs/guides/joining-a-team.md +++ b/docs/docs/guides/joining-a-team.md @@ -35,6 +35,18 @@ Use your invite token to register: zopp --server https://zopp.yourcompany.com:50051 join you@company.com ``` +If email verification is enabled on the server, you'll receive a 6-digit code at your email address: + +``` +📧 Email verification required. + +A verification code has been sent to: you@company.com +The code is valid for 15 minutes. + +Enter verification code (or 'r' to resend, 'q' to quit): 123456 +✓ Email verified successfully! +``` + This creates your device identity (principal) with secure keypairs stored in `~/.zopp/config.json`. :::tip diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md index cc44ed61..dbe94ad2 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -23,6 +23,35 @@ zopp can be configured using environment variables as an alternative to command- | `ZOPP_PROJECT` | Default project name | None | | `ZOPP_ENVIRONMENT` | Default environment name | None | +## Server Configuration (zopp-server) + +These variables configure the zopp server process. + +### Email Verification + +| Variable | Description | Default | +|----------|-------------|---------| +| `ZOPP_EMAIL_VERIFICATION_REQUIRED` | Require email verification for new users | `true` (when provider configured) | +| `ZOPP_EMAIL_PROVIDER` | Email provider: `resend` or `smtp` | None | +| `ZOPP_EMAIL_FROM` | Sender email address | Required when provider set | +| `ZOPP_EMAIL_FROM_NAME` | Sender display name | None | + +### Resend Provider + +| Variable | Description | Default | +|----------|-------------|---------| +| `RESEND_API_KEY` | Resend API key | Required for Resend | + +### SMTP Provider + +| Variable | Description | Default | +|----------|-------------|---------| +| `SMTP_HOST` | SMTP server hostname | Required for SMTP | +| `SMTP_PORT` | SMTP server port | `587` | +| `SMTP_USERNAME` | SMTP authentication username | None | +| `SMTP_PASSWORD` | SMTP authentication password | None | +| `SMTP_USE_TLS` | Enable TLS/STARTTLS | `true` | + ## Precedence Configuration is resolved in this order (highest to lowest priority): diff --git a/docs/docs/self-hosting/server.md b/docs/docs/self-hosting/server.md index 2e5dd862..d8da8132 100644 --- a/docs/docs/self-hosting/server.md +++ b/docs/docs/self-hosting/server.md @@ -100,6 +100,83 @@ DATABASE_URL=postgres://user:pass@localhost/zopp ./zopp-server invite create --e docker exec zopp-server zopp-server invite create --expires-hours 48 ``` +## Email Verification + +The server supports email verification for new users joining the system. When enabled, users must verify their email address before their principal is fully activated. + +### Configuration + +Email verification is configured via environment variables: + +```bash +# Enable/disable verification (default: true when email is configured) +ZOPP_EMAIL_VERIFICATION_REQUIRED=true + +# Sender configuration +ZOPP_EMAIL_FROM=noreply@yourdomain.com +ZOPP_EMAIL_FROM_NAME="Zopp Security" +``` + +### Email Providers + +Choose one of the supported email providers: + +#### Resend (recommended for production) + +```bash +ZOPP_EMAIL_PROVIDER=resend +RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +ZOPP_EMAIL_FROM=noreply@yourdomain.com +``` + +#### SMTP + +```bash +ZOPP_EMAIL_PROVIDER=smtp +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=user@example.com +SMTP_PASSWORD=your_password +SMTP_USE_TLS=true +ZOPP_EMAIL_FROM=noreply@yourdomain.com +``` + +### Docker Example + +```bash +docker run -d \ + --name zopp-server \ + -p 50051:50051 \ + -e DATABASE_URL=postgres://user:pass@host:5432/zopp \ + -e ZOPP_EMAIL_PROVIDER=resend \ + -e RESEND_API_KEY=re_xxxxxxxxxxxxx \ + -e ZOPP_EMAIL_FROM=noreply@yourdomain.com \ + -e ZOPP_EMAIL_FROM_NAME="Zopp Security" \ + ghcr.io/faiscadev/zopp-server:latest +``` + +### Verification Flow + +1. User runs `zopp join ` +2. Server sends a 6-digit verification code to the email +3. User enters the code in the CLI +4. Upon successful verification, the principal is activated + +The verification code: +- Expires after 15 minutes +- Allows 5 attempts per code +- Rate limited to 3 codes per hour per email + +### Disabling Verification + +To disable email verification (not recommended for production): + +```bash +ZOPP_EMAIL_VERIFICATION_REQUIRED=false +``` + +Without email verification configured, users can join immediately without verification. + ## Health Checks The server exposes health endpoints on port 8080: